# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # ux_q1.py - UX/UI interactions that are Q1 specific and use big screen, keyboard. # import utime, gc, ngu, sys, bip39 import uasyncio as asyncio from uasyncio import sleep_ms from charcodes import * from lcd_display import CHARS_W, CHARS_H, CursorSpec, CURSOR_SOLID, CURSOR_OUTLINE from exceptions import AbortInteraction, QRDecodeExplained from decoders import decode_qr_result from ubinascii import hexlify as b2a_hex from ubinascii import b2a_base64 from utils import problem_file_line, show_single_address from public_constants import MSG_SIGNING_MAX_LENGTH from glob import numpad # may be None depending on import order, careful class PressRelease: def __init__(self, need_release=KEY_ENTER+KEY_CANCEL): # Manage key-repeat: track last key, measure time it's held down, etc. self.need_release = need_release self.last_key = None self.num_repeats = 0 global numpad if not numpad: from glob import numpad async def wait(self): armed = None while 1: # two values here: # - (ms) time to wait before first key-repeat # - (ms) time between 2nd and Nth repeated events # - these values approved by @nvk rep_delay = 20 if self.num_repeats else 200 # busy-wait on key arrivial # - would like to use asyncio.wait_for_ms but causes random CancelledError's elsewhere for i in range(rep_delay//2): if not numpad.empty(): break await sleep_ms(2) if numpad.empty(): # nothing changed, do key repeat if self.last_key and numpad.key_pressed == self.last_key: self.num_repeats += 1 return self.last_key continue ch = numpad.get_nowait() if ch == numpad.ABORT_KEY: raise AbortInteraction() self.num_repeats = 0 if len(ch) > 1: # multipress: cancel press/release cycle and be a keyup # for other keys. armed = None continue if ch == '': # all keys are now UP self.last_key = None if armed: return armed elif ch in self.need_release: # no key-repeat on these ones armed = ch else: self.last_key = ch return ch async def ux_enter_number(prompt, max_value, can_cancel=True, value=''): # return the decimal number which the user has entered # - default/blank value assumed to be zero # - clamps large values to the max from glob import dis from math import log # allow key repeat on X only? press = PressRelease() value = str(value) max_w = int(log(max_value, 10) + 1) dis.clear() dis.text(None, -1, "CANCEL or ENTER when done." if can_cancel else "Enter number, ENTER when done.") while 1: # check width, go to two lines if needed? depends on prompt text if len(prompt) + 1 + max_w >= CHARS_W: dis.text(0, 3, prompt[-CHARS_W:]) # may still be truncated, oh well. if len(prompt) > CHARS_W: dis.text(0, 3, '⋯') bx = dis.text(2, 4, value) else: bx = dis.text(2, 4, prompt + ' ' + value) dis.show(cursor=CursorSpec(bx, 4, CURSOR_SOLID)) ch = await press.wait() if ch == KEY_ENTER: if not value: return 0 return min(max_value, int(value)) elif ch == KEY_DELETE: if value: value = value[0:-1] dis.text(0, 4, ' '*CHARS_W) elif ch == KEY_CLEAR: value = '' dis.text(0, 4, ' '*CHARS_W) elif ch == KEY_CANCEL: if can_cancel: # quit if they press CANCEL on any screen return None elif '0' <= ch <= '9': if len(value) == max_w: value = value[0:-1] + ch else: value += ch # cleanup leading zeros and such value = str(min(int(value), max_value)) async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100, prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False, placeholder=None, funct_keys=None, force_xy=None): # Get a text string. # - Should allow full unicode, NKDN # - but our font is mostly just ascii # - no control chars allowed either # - press QR -> do scan and use that text # - funct_keys => CTA msg, and map of Fn key to async-function which takes and returns new text # - TODO: regex validation for derviation paths? # - TODO: arrowing around, insertion cursor, delete-left vs -right, etc # - if unlimited length, then we allow newlines and CANCEL is only way out. # - multiline entries don't mean newlines are allowed, because we often have to wrap # to make longer single-line value onto screen # - confirm_exit default False here, because so easy to re-enter w/ qwerty, True on mk4 from glob import dis MAX_LINES = 7 # without scroll can_scroll = False value = value or '' # map from what they entered, to allowed char. None if not allowed char # - can case fold if desired 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 line_len = CHARS_W-2 y = 2 if max_len is None: # try hard to support "unlimited" entry, with scrolling and all that. num_lines = CHARS_H-2 line_len = CHARS_W can_scroll = True elif max_len <= line_len: # single-line or perhaps shorter value line_len = max_len num_lines = 1 y = 4 elif max_len == 100: # passphrase case, handle nicely line_len = 25 num_lines = 4 else: # non-scrolling but still multi-line mode num_lines, runt = divmod(max_len, line_len) if runt: num_lines += 1 assert num_lines <= MAX_LINES, num_lines # too big to fit w/o scroll if force_xy: x, y = force_xy else: dis.clear() if not can_scroll and not force_xy: # Normal, no-scrolling case if funct_keys: msg, funct_keys = funct_keys dis.text(None, -2, msg, dark=True) if b39_complete or scan_ok: msg = [] if b39_complete: msg.append(KEY_TAB + " to auto-complete.") if scan_ok: msg.append(KEY_QR + " to scan.") dis.text(None, -1, ' '.join(msg), dark=True) elif num_lines <= 2: # show this dumb CTA only if screen mostly blank dis.text(None, -1, "CANCEL or ENTER when done.", dark=True) dis.text(None, y-2, prompt) x = dis.draw_box(None, y-1, line_len, num_lines, dark=True) elif can_scroll: # Scrolling, max-screen space mode (unlimited length for notes) dis.text(None, 0, prompt) # maybe some guide "icons" in top-left msg = '' if scan_ok: msg += KEY_QR if b39_complete: msg += KEY_TAB if msg: dis.text(-1, 0, msg, dark=True) dis.text(0, 0, KEY_TAB, dark=True) dis.text(None, 1, '┅'*CHARS_W, dark=True) x = 0 y = 2 # NOTE: # - x,y here are top left of entry area # - does not allow cursor movement, always appending to end (for now) # no key-repeat on certain keys err_msg = last_err = None press = PressRelease() exit_armed = False while 1: dis.clear_box(x, y, line_len, num_lines) if not can_scroll: # show error msg, until they type anything to clear it if err_msg: dis.text(None, y+num_lines+1, err_msg, dark=True) err_msg = None last_err = True elif last_err: dis.text(None, y+num_lines+1, '') last_err = False if not can_scroll or not value: if not value: bx = 0 n = 0 if placeholder: dis.text(x, y, placeholder, dark=True) elif not can_scroll: for n, ln_pos in enumerate(range(0, len(value), line_len)): ln = value[ln_pos:ln_pos+line_len] dis.text(x, y+n, ln) bx = len(ln) else: # scrollable case lines = [] for ln in value.split('\n'): if len(ln) <= line_len: lines.append(ln) else: for pp in range(0, len(ln), line_len): lines.append(ln[pp:pp+line_len]) for n, ln in enumerate(lines[-MAX_LINES:]): dis.text(x, y+n, ln) bx = len(ln) top_y = max(0, len(lines) - MAX_LINES) if top_y: dis.scroll_bar(top_y, len(lines), MAX_LINES) # decide cursor location # - if on final possible location, adjust over top of final char cur = CursorSpec(min(x+bx, x+line_len-1), y+n, CURSOR_OUTLINE) dis.show(cursor=cur) ch = await press.wait() if ch != KEY_CANCEL: exit_armed = False if ch == KEY_ENTER: if can_scroll: value += '\n' else: if len(value) >= min_len: break else: err_msg = 'Need %d characters at least.' % min_len elif ch == KEY_DELETE or ch == KEY_LEFT: if len(value) > 0: # delete last char value = value[:-1] elif ch == KEY_CLEAR: value = '' elif ch == KEY_CANCEL: if confirm_exit: if exit_armed: value = None break err_msg = 'Confirm exit w/o change?' exit_armed = True continue else: if can_scroll: # CANCEL/TAB are only way out in scrolling mode # - cleanup blank lines at end, etc value = value.strip() break value = None break elif can_scroll and ch == KEY_TAB: # allow tab as escape from multiline mode value = value.strip() break elif ch == KEY_QR and scan_ok: # Insert or replace? I think replace in most cases, but not if long msg. # - always show result ss = dis.save_state() zz = QRScannerInteraction() got = await zz.scan_text('Scan any QR or Barcode for text.') if got: # not canceled, etc if not value or not can_scroll: # whole QR should be it, no more editing # - but not if too long if not max_len or len(got) <= max_len: value = got if len(value) >= 60: break else: err_msg = "QR data too long! (max %d)" % max_len else: # add onto end, if inf length supported, and they aren't on first char # - adds a line break for them too if value[-1] != '\n': value += '\n' value += got dis.restore_state(ss) elif b39_complete and ch == KEY_TAB: # match case and auto-complete BIP-39 word if we can # - search backwards for alpha chars, up to 5 # - stop on first non-letter # - break if case changes, so "ZooAct" gives "Act" pref = [] for b in reversed(value[-4:]): if not b: break if 'a' <= b.lower() <= 'z': pref.insert(0, b) if len(pref)>=1 and b.isupper() != pref[0].isupper(): break else: break if not pref: #err_msg = 'Need some letters first.' continue pref = ''.join(pref) exact, nextchars, is_word = bip39.next_char(pref.lower()) if not is_word and len(nextchars) == 1: # only one possible next char, so complete for them # example "yo" => "you" is_word = pref + nextchars[0] #N OTE: if exact and not is_word: # example "act" -> could be "act" or "actor" etc. # but they pressed auto-complete and not space, so they want next char info if is_word: # got a match; append it if pref.isupper(): # all upper case, so append w/ same # - Titlecase will just happen w/o any code here is_word = is_word.upper() value += is_word[len(pref):] elif not nextchars: err_msg = 'Not a BIP-39 word: ' + pref elif len(nextchars) < 18: # 'sta' and other s-prefixes can have many choices! # 'act' could be by itself, or 'actual', etc. if exact: nextchars += '␣' err_msg = 'Press next key: ' + nextchars else: err_msg = 'Need more letters.' elif funct_keys and (ch in funct_keys): # replace w/ function output ... might do a transform, or not value = await funct_keys[ch](value) else: ch = ch_remap(ch) if ch is not None: if not max_len or len(value) < max_len: value += ch else: value = value[0:max_len-1] + ch return value def ux_show_phish_words(dis, words): # Show the anti-phishing words x = 34//2 y = 7 if not words: # just clear line dis.clear_box(0, y, CHARS_W, 1) else: dis.text(x - len(words[0]) - 1, y, words[0]) dis.text(x + 1, y, words[1]) def ux_show_pin(dis, pin, subtitle, prefix, is_confirmation, force_draw, footer=None, randomize=None): # Draw PIN during entry / reentry / changing or setting # verticals rnd_y = 0 # jammed in at top; doesn't look great but rarely used? foot_y = -1 # footer at foot y = 4 # main focus area, center line if randomize: y += 1 # for MAX_PIN_PART_LEN==6, and one char margin both sides x = 6 w = 8 # position of prefix/suffix digits ppx = x + 2 ssx = x + w + 6 if force_draw: dis.clear() if randomize: # 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" # show mapping of numbers vs. PIN digits dis.text(1, rnd_y+0, ' ' + ' '.join(randomize[1:]) +' '+ randomize[0] + ' ', invert=1) dis.text(1, rnd_y+1, '↳ 1 2 3 4 5 6 7 8 9 0') dis.text(x+w+2, y, '⋯', dark=True) if footer: # ie. '1 failures, 12 tries left' dis.text(None, foot_y, footer, dark=True) elif is_confirmation: dis.text(None, foot_y, "Confirm PIN value") else: dis.clear_box(ppx, y, 6, 1) dis.clear_box(ssx, y, 6, 1) if not prefix: ux_show_phish_words(dis, None) # prefix/not prefix can change anytime, so redraw this stuff dis.draw_box(x, y-1, w, 1, dark=bool(prefix)) dis.draw_box(x+w+4, y-1, w, 1, dark=not bool(prefix)) prompt = "Enter first part of PIN" if not prefix else "Enter second part of PIN" if not subtitle: dis.text(None, y-2, prompt) else: # "New Main PIN" and similar dis.text(None, y-3, subtitle, dark=False) dis.text(None, y-2, prompt, dark=True) # show dots active = '•' * len(prefix or pin) if prefix: # show both first part and second suffix = '•' * len(pin) dis.text(ppx, y, active, dark=True) cur_x = dis.text(ssx, y, suffix) else: # just showing first part cur_x = dis.text(ppx, y, active) dis.clear_box(ssx, y, 6, 1) if len(pin) == 6: # cursor on final 6th digit dis.show(cursor=CursorSpec(cur_x-1, y, CURSOR_OUTLINE)) else: dis.show(cursor=CursorSpec(cur_x, y, CURSOR_SOLID)) async def ux_login_countdown(sec): # Show a countdown, which may need to # run for multiple **days** # - test with: ./simulator.py --q1 -g --eff --set lgto=60 # - test with: ./simulator.py --q1 -g --eff --set lgto=3600 # - test with: ./simulator.py --q1 -g --eff --set lgto=2419200 from glob import dis from utime import ticks_ms, ticks_diff from utils import pretty_short_delay, pretty_delay y = 1 dis.clear() dis.text(None, y, "Login countdown in effect.", invert=1) dis.text(None, y+2, "Must wait:") st = ticks_ms() while sec > 0: txt = pretty_delay(sec) if sec > 12*3600 else pretty_short_delay(sec) dis.text(None, y+4, txt) dis.busy_bar(1) # this should be more accurate, errors were accumulating now = ticks_ms() dt = 1000 - ticks_diff(now, st) await sleep_ms(dt) st = ticks_ms() sec -= 1 dis.busy_bar(0) def ux_render_words(words, leading_blanks=0): # 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 rv = [''] * leading_blanks num_words = len(words) if num_words == 12: for y in range(6): # no need to use NOWRAP here, will always fit (2 word columns) 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(OUT_CTRL_NOWRAP+'%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 == 2: # simple version for first & last words, used only during login to spending policy X = 14 Y = y+1 dis.text(X-7, Y, 'FIRST: %s' % words[0]) dis.text(X-4, Y+1, '⋯') dis.text(X-6, Y+2, 'LAST: %s' % words[-1]) return [ (X, Y), (X, Y+2) ] if num_words == 12: cols = 2 xpos = [2, 18] else: assert num_words in (18, 24) 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 = 4 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, line2=None): # 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 from ux import ux_confirm assert num_words and prompt not24 = (num_words != 24) def redraw_words(wrds=None): if not wrds: wrds = ['' for _ in range(num_words)] dis.clear() dis.text(None, 0, prompt, invert=1) Y = 2 if not24 else 1 if line2 and not24: # add second line, if provided, but only if words length < 24 # currently only used to show backup filename during backup pwd entry dis.text(None, 1, line2, invert=1) Y += 1 p = ux_draw_words(Y, num_words, wrds) return wrds, p words, pos = redraw_words() word_num = 0 value = '' err_msg = last_err = None press = PressRelease() last_words = [] while 1: final = (word_num == num_words) if final: # useful to show final word on screen, even tho confirm not needed err_msg = 'Press ENTER if all done.' if not has_checksum else \ 'Valid words! Press ENTER.' cur = None else: x, y = pos[word_num] ln = len(value) if ln == 8: # outline-style cursor if on top of final possible location cur = CursorSpec(x+ln-1, y, CURSOR_OUTLINE) else: cur = CursorSpec(x+ln, y, CURSOR_SOLID) 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, dark=True) 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_QR: try: got = await QRScannerInteraction.scan('Scan seed from a QR code') what, vals = decode_qr_result(got, expect_secret=True) except QRDecodeExplained as e: err_msg = str(e) redraw_words(words) continue if what != "words": err_msg = "Must be seed words, not %s" % what elif num_words != len(vals[0]): err_msg = "Must be seed of length %d, not %s" % (num_words, len(vals[0])) else: words = vals[0] # offer just the actual imported csum if user deletes csum word last_words = [words[-1]] word_num = num_words # needs redraw, empty on error with error below # if success, qr imported words shown to user redraw_words(words) elif ch == KEY_ENTER: if final: 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() if not await ux_confirm("Everything you've entered will be lost."): dis.restore_state(tmp) continue return None elif final: # below options not allowed if all words already provided continue 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 not maybe: 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: err_msg = 'Next key: ' + nextchars if done_cb: await done_cb(words) return 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]+'-', dark=True) dis.text(2, -1, ''+hx[32:], dark=True) dis.show() # return funct to draw updating part return update class QRScannerInteraction: def __init__(self): pass @staticmethod async def scan(prompt, line2=None, enter_quits=False): # draw animation, while waiting for them to scan something # - CANCEL to abort # - returns a string, BBQr object or None. from glob import dis, SCAN from ux import ux_wait_keydown frames = [ 1, 2, 3, 4, 5, 4, 3, 2 ] if not SCAN: raise QRDecodeExplained("Hardware fault.") dis.clear() dis.text(None, -2, prompt) if line2: dis.text(None, -1, line2, dark=True) dis.show() task = asyncio.create_task(SCAN.scan_once()) escape = KEY_CANCEL + (KEY_ENTER if enter_quits else '') ph = 0 while 1: if task.done(): data = await task break dis.image(None, 40, 'scan_%d' % frames[ph]) ph = (ph + 1) % len(frames) # wait for key or 250ms animation delay ch = await ux_wait_keydown(escape, 250) if ch and (ch in escape): data = None break if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass # clear screen right away so user knows we got it dis.clear() dis.show() return data async def scan_general(self, prompt, convertor, line2=None, enter_quits=False): # Scan stuff, and parse it .. raise QRDecodeExplained if you don't like it # continues until something is accepted problem = line2 while 1: try: got = await self.scan(prompt, line2=problem, enter_quits=enter_quits) if got is None: return None # Decode BBQr but not anything more complex return convertor(got) except QRDecodeExplained as exc: problem = str(exc) continue except Exception as exc: # import sys; sys.print_exception(exc) problem = "Unable to decode QR" continue async def scan_text(self, prompt): # Scan and return a text string. For things like BIP-39 passphrase # and perhaps they are re-using a QR from something else. Don't act on contents. def convertor(got): return decode_qr_result(got, expect_text=True) return await self.scan_general(prompt, convertor) async def scan_json(self, prompt): # Scan for a BBQr and a BBQr object. Converts sometimes? def convertor(got): file_type, _, data = decode_qr_result(got, expect_bbqr=True) if file_type == 'U': data = data.strip() if data[:1] == b'{' and data[-1:] == b'}': file_type = 'J' if file_type != 'J': raise QRDecodeExplained('Expected JSON data') try: import json return json.loads(data) except: raise QRDecodeExplained('Unable to decode JSON data') return await self.scan_general(prompt, convertor) async def scan_for_addresses(self, prompt, line2=None): # accept only payment addresses; strips BIP-21 junk that might be there # - always a list result, might be size one from utils import decode_bip21_text def addr_taster(got): # could be muliple-line text file via BBQR or single line got = decode_qr_result(got, expect_text=True) try: rv = [] for ln in got.split(): what, args = decode_bip21_text(ln) if what == 'addr': rv.append(args[1]) if rv: return rv except QRDecodeExplained: raise except: pass raise QRDecodeExplained("Not a payment address?") return await self.scan_general(prompt, addr_taster, line2=line2, enter_quits=True) async def scan_anything(self, expect_secret=False, tmp=False): # start a QR scan, and act on what we find, whatever it may be. from ux import ux_show_story from pincodes import pa problem = None while 1: prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \ 'Scan XPRV or Seed Words, or CANCEL' try: got = await self.scan(prompt, line2=problem) if got is None: return # Figure out what we got. what, vals = decode_qr_result(got, expect_secret=expect_secret) break except QRDecodeExplained as exc: problem = str(exc) continue except Exception: # import sys; sys.print_exception(exc) problem = "Unable to decode QR" continue if pa.hobbled_mode: # block most imports in hobbled mode. # - specific checks in place for teleport (PSBT is okay) from ccc import sssp_spending_policy whitelist = {'psbt', 'addr', 'vmsg', 'text', 'xpub', 'teleport' } sv_ok = sssp_spending_policy('okeys') if sv_ok: # seed vault, and tmp seeds are okay with user, even in hobble mode whitelist.update({'xprv', 'words'}) if what not in whitelist: await ux_show_story("Blocked when Spending Policy is in force.", title='Sorry') return if what == 'xprv': from actions import import_extended_key_as_secret text_xprv, = vals await import_extended_key_as_secret(text_xprv, tmp) return if what == 'words': from seed import commit_new_words, set_ephemeral_seed_words # dirty API words, = vals if tmp: await set_ephemeral_seed_words(words, 'From QR') else: await commit_new_words(words) return if what == 'psbt': decoder, psbt_len, got = vals await qr_psbt_sign(decoder, psbt_len, got) elif what == 'txn': bin_txn, = vals await ux_visualize_txn(bin_txn) elif what == 'addr': proto, addr, args = vals await ux_visualize_bip21(proto, addr, args) elif what == "multi": from auth import maybe_enroll_xpub ms_config, = vals try: maybe_enroll_xpub(config=ms_config) except Exception as e: await ux_show_story( 'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) elif what == "wif": data, = vals wif_str, key_pair, compressed, testnet = data from wif import ux_visualize_wif await ux_visualize_wif(wif_str, key_pair, compressed, testnet) elif what == "vmsg": data, = vals from msgsign import verify_armored_signed_msg await verify_armored_signed_msg(data) elif what == "smsg": data, = vals from auth import approve_msg_sign from msgsign import msg_signing_done await approve_msg_sign(None, None, None, msg_sign_request=data, kill_menu=True, approved_cb=msg_signing_done) elif what == 'text' or what == 'xpub': # we couldn't really decode it. txt, = vals await ux_visualize_textqr(txt) elif what == 'teleport': from teleport import kt_incoming await kt_incoming(*vals) else: await ux_show_story(what, title='Unhandled') async def qr_psbt_sign(decoder, psbt_len, raw): # Got a PSBT coming in from QR scanner. Sign it. # - similar to auth.sign_psbt_file() from auth import UserAuthorizedAction, ApproveTransaction from ux import the_ux from sffile import SFFile from auth import TXN_INPUT_OFFSET, psbt_encoding_taster if raw != 'PSRAM': # might already be in place # copy to PSRAM, and convert encoding at same time if isinstance(raw, str): raw = raw.encode() _, output_encoder, _ = psbt_encoding_taster(raw[:10], psbt_len) total = 0 with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out: if not decoder: total += out.write(raw) else: for here in decoder.more(raw): out.write(here) total += len(here) # might have been whitespace inflating initial estimate of PSBT size assert total <= psbt_len psbt_len = total else: with SFFile(TXN_INPUT_OFFSET, length=psbt_len) as out: taste = out.read(10) _, output_encoder, _ = psbt_encoding_taster(taste, psbt_len) UserAuthorizedAction.cleanup() UserAuthorizedAction.active_request = ApproveTransaction( psbt_len, input_method="qr", output_encoder=output_encoder ) the_ux.push(UserAuthorizedAction.active_request) async def ux_visualize_txn(bin_txn): # Show the user a signed transaction on-screen. # - longer-term we may offer more data about address ownership, etc # - be careful not to claim things we cannot prove w/o UTXO from confirmed blocks # - .. like the fee, which would be useful from ux import ux_show_story from io import BytesIO from psbt import calc_txid from serializations import CTransaction txn = CTransaction() try: txn.deserialize(BytesIO(bin_txn)) if (n := len(txn.vin)) == 1: msg = '1 input, ' else: msg = '%d inputs, ' % n if (n := len(txn.vout)) == 1: msg += '1 output' else: msg += '%d outputs' % n # add txid txid = calc_txid(BytesIO(bin_txn), (0, len(bin_txn))) msg += '\n\nTxid:\n' + b2a_hex(txid).decode() except Exception as exc: # sys.print_exception(exc) msg = "Unable to deserialize" await ux_show_story(msg, title="Signed Transaction") async def ux_visualize_bip21(proto, addr, args): # Show details of BIP-21 URL # - imho, a bare address is a valid BIP-21 URL so we come here too # - validate address ownership on request from ux import ux_show_story from chains import current_chain msg = show_single_address(addr) + '\n\n' args = args or {} if 'amount' in args: try: amt = args.pop('amount') whole, _, frac = amt.partition('.') assert whole.isdigit() assert len(whole) <= 8 assert len(frac) <= 8 sats = int((whole or '0') + (frac + '00000000')[:8]) msg += 'Amount: %s %s\n' % current_chain().render_value(sats) except: msg += 'Amount: (corrupt)\n' for fn in ['label', 'message', 'lightning']: if fn in args: val = args.pop(fn) msg += '%s%s: %s\n' % (fn[0].upper(), fn[1:], val) if args: msg += 'And values for: ' + ', '.join(args) msg += "\n" msg += '\nPress (1) to verify ownership.' ch = await ux_show_story(msg, title="Payment Address", escape='1') if ch == '1': from ownership import OWNERSHIP await OWNERSHIP.search_ux(addr, args) async def qr_msg_sign_done(signature, address, text): from ux import ux_show_story from msgsign import rfc_signature_template from export import export_by_qr sig = b2a_base64(signature).decode('ascii').strip() while True: ch = await ux_show_story("Press ENTER to export signature QR only, " "(0) to export full RFC template, " "CANCEL if done.", escape="0") if ch == "x": break if ch == "y": await export_by_qr(sig, "Signature", "U") if ch == "0": armored_str = "".join(rfc_signature_template(addr=address, msg=text, sig=sig)) await show_bbqr_codes("U", armored_str, "Armored MSG") async def qr_sign_msg(txt): from msgsign import ux_sign_msg await ux_sign_msg(txt, approved_cb=qr_msg_sign_done, kill_menu=True) async def ux_visualize_textqr(txt, maxlen=MSG_SIGNING_MAX_LENGTH): # Show simple text. Don't crash on huge things, but be # able to show a full xpub. from ux import ux_show_story txt_len = len(txt) escape = "0" if txt_len > maxlen: escape = None txt = txt[0:maxlen] + '...' msg = "%s\n\nAbove is text that was scanned. " % txt if escape: msg += " Press (0) to sign the text. " ch = await ux_show_story(title="Simple Text", msg=msg, escape=escape) if escape and (ch == "0"): await qr_sign_msg(txt) async def show_bbqr_codes(type_code, data, msg, already_hex=False): # Compress, encode and split data, then show it animated... # - happily goes to version 40 if needed # - needs to pre-render the QR to get animation to be faster # - version of first QR is used for all ther others # - screen resolution is considered when picking QR version number # - data may point to output side of PSRAM area # - Should always do zlib compression (because it nearly always helps) # - BUT: need zlib compress (not present) .. delayed for now from bbqr import TYPE_LABELS, int2base36, b32encode, num_qr_needed from glob import PSRAM, dis from ux import ux_wait_keydown import uqr assert type_code in TYPE_LABELS dis.fullscreen('Generating BBQr...', .1) if already_hex: encoding = 'H' data_len = len(data) // 2 else: # default to Base32, because always best option encoding = '2' if isinstance(data, str): # 'U'/'J' payloads are UTF-8 text; b32encode consumes the UTF-8 # bytes, so convert now to keep length/slicing consistent (else # multi-byte chars overflow target_vers -> assert below trips) data = data.encode() data_len = len(data) # try a few select resolutions (sizes) in order such that we use either single QR # or the least-dense option that gives reasonable number of QR's target_vers, num_parts, part_size = num_qr_needed(encoding, data_len) assert num_parts * part_size >= data_len pos = 0 force_version = 40 for pkt in range(num_parts): # BBQr header hdr = 'B$' + encoding + type_code + int2base36(num_parts) + int2base36(pkt) assert pos < data_len, (pkt, pos, data_len) if already_hex: # not encoding, just hex string hp = pos*2 body = data[hp:hp+(part_size*2)] else: # encode bytes to base32 encoding body = b32encode(data[pos:pos+part_size]) pos += part_size # first packet, want to discover a working small value for QR version if pkt == 0: mnv = 10 if num_parts > 1 else 1 else: mnv = force_version # do the hard work qr_data = uqr.make(hdr+body, min_version=mnv, max_version=force_version, encoding=uqr.Mode_ALPHANUMERIC) # save the rendered QR if pkt == 0: # common values for all parts scan_w, w, raw = qr_data.packed() raw_qr_size = len(raw) qr_size = (raw_qr_size + 3) & ~0x3 # align4 force_version = qr_data.version() assert force_version <= target_vers else: _, _, raw = qr_data.packed() PSRAM.write_at(qr_size * pkt, qr_size)[0:raw_qr_size] = raw del qr_data dis.progress_sofar((pkt+1), num_parts) # display rate (plus time to send to display, etc) ms_per_each = 200 # hide Generating... text dis.fullscreen(' ', 1) dis.show() ch = None while not ch: for pkt in range(num_parts): buf = PSRAM.read_at(qr_size * pkt, raw_qr_size) dis.draw_qr_display( (scan_w, w, buf), msg, True, None, None, False, partial_bar=((pkt, num_parts) if num_parts else None)) if num_parts == 1: # no need for animation ch = await ux_wait_keydown() break # wait for key or animation delay ch = await ux_wait_keydown(None, ms_per_each) if ch: break # after QR drawing, we need to correct some pixels dis.real_clear() # EOF