# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # actions.py # # Every function here is called directly by a menu item. They should all be async. # import ckcc, pyb, version, uasyncio, sys, uos, chains from uhashlib import sha256 from uasyncio import sleep_ms from ubinascii import hexlify as b2a_hex from utils import imported, problem_file_line, get_filesize, encode_seed_qr from utils import xfp2str, B2A, txid_from_fname, wipe_if_deltamode from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words from export import export_contents, make_summary_file, make_descriptor_wallet_export from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export from export import generate_unchained_export, generate_electrum_wallet, make_key_expression_export from files import CardSlot, CardMissingError, needs_microsd from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH from glob import settings from pincodes import pa from menu import start_chooser, MenuSystem, MenuItem from version import MAX_TXN_LEN from charcodes import KEY_NFC, KEY_QR, KEY_CANCEL async def start_selftest(*args): # selftest is harmless, no need to warn anymore, # but this layer saves memory in typical cases with imported('selftest') as st: await st.start_selftest() async def show_bag_number(*a): import callgate bn = callgate.get_bag_number() or 'UNBAGGED!' await ux_show_story('''\ Your new Coldcard should have arrived SEALED in a bag with the above number. Please take a moment to confirm the number and look for any signs of tampering. Take pictures and contact support@coinkite if you have concerns.''', title=bn) async def accept_terms(*a): # do nothing if they have accepted the terms once (ever), otherwise # force them to read message... if settings.get('terms_ok'): return while 1: ch = await ux_show_story("""\ By using this product, you are accepting our Terms of Sale and Use. Read the full document at: coldcard.com/legal Press %s to accept terms and continue.""" % OK, escape='7') if ch == 'y': break await show_bag_number() # Note fact they accepted the terms. Annoying to do more than once. settings.set('terms_ok', 1) settings.save() async def view_ident(*a): # show the XPUB, and other ident on screen import callgate, stash msg = "" if stash.bip39_passphrase: msg += 'BIP-39 passphrase is in effect.\n\n' elif pa.tmp_value: msg += 'Temporary seed is in effect.\n\n' tpl = '''\ Master Key Fingerprint: {xfp} USB Serial Number: {serial} Extended Master Key: {xpub} ''' my_xfp = settings.get('xfp', 0) xpub = settings.get('xpub', None) msg += tpl.format(xpub=(xpub or '(none yet)'), xfp=xfp2str(my_xfp), serial=version.serial_number()) bn = callgate.get_bag_number() if bn: msg += '\nShipping Bag:\n %s\n' % bn if xpub: msg += '\nPress %s to show QR code of xpub.' % ( KEY_QR if version.has_qwerty else "(3)" ) ch = await ux_show_story(msg, escape=('3'+KEY_QR if xpub else None)) if ch in '3'+KEY_QR: # show the QR from ux import show_qr_code await show_qr_code(xpub, False) async def show_settings_space(*a): percentage_capacity = int(settings.get_capacity() * 100) if percentage_capacity < 10: percentage_capacity = 10 await ux_show_story('Settings storage space in use:\n\n' ' %d%%' % percentage_capacity) async def show_mcu_keys_left(*a): import callgate avail, used, total = callgate.mcu_key_usage() await ux_show_story('MCU key slots remaining:\n\n %d of %d' % (avail, total)) async def maybe_dev_menu(*a): from version import is_devmode if not is_devmode: ok = await ux_confirm('Developer features could be used to weaken security or release key material.\n\nDo not proceed unless you know what you are doing and why.') if not ok: return None from flow import DevelopersMenu return DevelopersMenu async def dev_enable_vcp(*a): # Enable USB serial port emulation, for devs. # Mk3 and earlier only. # from usb import is_vcp_active if is_vcp_active(): await ux_show_story("""The USB virtual serial port is already enabled.""") return was = pyb.usb_mode() pyb.usb_mode(None) if was and 'MSC' in was: pyb.usb_mode('VCP+MSC') else: pyb.usb_mode('VCP+HID') # allow REPL access ckcc.vcp_enabled(True) await ux_show_story("""\ The USB virtual serial port has now been enabled. Use a real computer to connect to it.""") async def dev_enable_disk(*a): # Enable disk emulation, which allows them to change code. # Mk3 and earlier only. # cur = pyb.usb_mode() if cur and 'MSC' in cur: await ux_show_story("""The USB disk emulation is already enabled.""") return # serial port and disk (but no HID-based USB protocol) pyb.usb_mode(None) pyb.usb_mode('VCP+MSC') await ux_show_story("""\ The disk emulation has now been enabled. Your code can go into /lib. \ Keep tmp files and other junk out!""") async def dev_enable_protocol(*a): # Turn off disk emulation. Keep VCP enabled, since they are still devs. # Mk3 and earlier cur = pyb.usb_mode() if cur and 'HID' in cur: await ux_show_story('Coldcard USB protocol is already enabled (HID mode)') return if settings.get('du', 0): await ux_show_story('USB disabled in settings.') return # might need to reset stuff? from usb import enable_usb # reset and re-enable pyb.usb_mode(None) enable_usb() # enable REPL ckcc.vcp_enabled(True) await ux_show_story('Back to normal USB mode.') async def microsd_upgrade(menu, label, item): # Upgrade vis MicroSD card # - search for a particular file # - verify it lightly # - erase serial flash # - copy it over (slow) # - reboot into bootloader, which finishes install from glob import dis, PSRAM from files import dfu_parse from utils import check_firmware_hdr from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_MAX_LENGTH_MK4 if version.has_battery: import battery lvl = battery.get_batt_threshold() if lvl is not None and lvl <= 1: await ux_show_story("Battery power is somewhat low right now. " "Please install fresh batteries or connect USB power during upgrade process.", title="Low Battery") return force_vdisk = item.arg fn = await file_picker(suffix='.dfu', min_size=0x7800, max_size=FW_MAX_LENGTH_MK4, force_vdisk=force_vdisk) if not fn: return failed = None with CardSlot(force_vdisk=force_vdisk) as card: with card.open(fn, 'rb') as fp: try: offset, size = dfu_parse(fp) # read just the signature header hdr = bytearray(FW_HEADER_SIZE) fp.seek(offset + FW_HEADER_OFFSET) rv = fp.readinto(hdr) assert rv == FW_HEADER_SIZE # check header values failed = check_firmware_hdr(hdr, size) except Exception as exc: # recover from garbage DFU files. failed = "Failed: " + str(exc) if not failed: # copy binary into PSRAM fp.seek(offset) dis.fullscreen("Loading...") buf = bytearray(0x20000) pos = 0 while pos < size: dis.progress_bar_show(pos/size) here = fp.readinto(buf) if not here: break PSRAM.write(pos, buf) pos += here if failed: await ux_show_story(failed, title='Sorry!') return # continue process... from auth import FirmwareUpgradeRequest m = FirmwareUpgradeRequest(hdr, size, psram_offset=0) the_ux.push(m) async def start_dfu(*a): from callgate import enter_dfu enter_dfu(0) # NOT REACHED async def reset_self(*a): import machine machine.soft_reset() # NOT REACHED async def initial_pin_setup(*a): # First time they select a PIN of any type. from login import LoginUX lll = LoginUX() title = 'Choose PIN' ch = await ux_show_story('''\ Pick the main wallet's PIN code now. Be more clever, but an example: 123-4567 It has two parts: prefix (123-) and suffix (-4567). \ Each part must be between 2 to 6 digits long. Total length \ can be as long as 12 digits. The prefix part determines the anti-phishing words you will \ see each time you login. Your new PIN protects access to this Coldcard device and is not a factor in the wallet's \ seed words or private keys. THERE IS ABSOLUTELY NO WAY TO RECOVER A FORGOTTEN PIN! Write it down. ''', title=title) if ch != 'y': return while 1: ch = await ux_show_story('''\ There is ABSOLUTELY NO WAY to 'reset the PIN' or 'factory reset' the Coldcard if you forget the PIN. DO NOT FORGET THE PIN CODE. Press (6) to prove you read to the end of this message.''', title='WARNING', escape='6') if ch == 'x': return if ch == '6': break # do the actual picking pin = await lll.get_new_pin() del lll if pin is None: return # A new pin is to be set! from glob import dis dis.fullscreen("Saving...") try: dis.busy_bar(True) assert pa.is_blank() pa.change(new_pin=pin) # check it? kinda, but also get object into normal "logged in" state pa.setup(pin) ok = pa.login() assert ok # must re-read settings after login, because they are encrypted # with a key derived from the main secret. settings.set_key() settings.load() except Exception as e: print("Exception: %s" % e) finally: dis.busy_bar(False) # Allow USB protocol, now that we are auth'ed from usb import enable_usb enable_usb() from flow import EmptyWallet return MenuSystem(EmptyWallet) async def block_until_login(): # # Force user to enter a valid PIN. # from login import LoginUX from ux import AbortInteraction # maybe show a calculator rather than login screen try: if version.has_qwerty and settings.get('calc', 0): with imported('calc') as calc: await calc.login_repl() return except: pass # do they want a randomized (shuffled) keypad? rnd_keypad = settings.get('rngk', 0) # single key that "kills" self if pressed on "words" screen kill_btn = settings.get('kbtn', None) rv = None # might already be logged-in if _skip_pin used while not pa.is_successful(): lll = LoginUX(rnd_keypad, kill_btn) try: rv = await lll.try_login(bypass_pin=None) if rv: break except AbortInteraction: # not allowed! pass return rv async def show_nickname(nick): # Show a nickname for this coldcard (as a personalization) # - no keys here, just show it until they press anything from glob import dis from ux import ux_wait_keyup import callgate dis.clear() if dis.has_lcd: from lcd_display import CHARS_W, CHARS_H from utils import word_wrap lines = list(word_wrap(nick, CHARS_W)) y = ((CHARS_H - len(lines) + 1) // 2) - 1 for n, ln in enumerate(lines): dis.text(None, y+n, ln) else: from display import FontLarge, FontSmall if dis.width(nick, FontLarge) <= dis.WIDTH: dis.text(None, 21, nick, font=FontLarge) else: dis.text(None, 27, nick, font=FontSmall) dis.show() ch = await ux_wait_keyup(flush=True) if ch.upper() == settings.get('kbtn', None): # support kill btn on nick screen too callgate.fast_wipe(False) async def pick_killkey(*a): # Setting: kill seed sometimes (requires mk4) if version.has_qwerty: msg = '''\ If you press this key at any point during login, your seed phrase will be immediately wiped.''' else: msg = '''\ If you press this key while the anti- phishing words are shown during login, \ your seed phrase will be immediately wiped. Best if this does not match the first number of the second half of your PIN.''' if await ux_show_story(msg) != 'y': return from choosers import kill_key_chooser start_chooser(kill_key_chooser) async def pick_scramble(*a): # Setting: scrambled keypad or normal if await ux_show_story("When entering PIN, randomize the order of the key numbers, " "so that cameras and shoulder-surfers are defeated.") != 'y': return from choosers import scramble_keypad_chooser start_chooser(scramble_keypad_chooser) async def pick_nickname(*a): # from settings menu, enter a nickname from nvstore import SettingsObject # Value is not stored with normal settings, it's part of "prelogin" settings # which are encrypted with zero-key. s = SettingsObject.prelogin() k = "nick" nick = s.get(k, '') if not nick: ch = await ux_show_story("You can give this Coldcard a nickname" " and it will be shown before login.") if ch != 'y': return nn = await ux_input_text(nick, confirm_exit=False, prompt="Enter Nickname") if nn is None or (nick == nn): return # user exit & same value - noop from glob import dis dis.fullscreen("Saving...") dis.busy_bar(True) if not nn: s.remove_key(k) else: s.set(k, nn.strip()) s.save() dis.busy_bar(False) del s async def logout_now(*a): # wipe memory and lock up from utils import clean_shutdown clean_shutdown() async def login_now(*a): # wipe memory and reboot from utils import clean_shutdown clean_shutdown(2) async def virgin_help(*a): await ux_show_story("""\ 8 = Down (do it!) 5 = Up OK = Checkmark X = Cancel/Back 0 = Go to top More on our website: coldcardwallet .com """) 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', item.arg, done_cb=seed.commit_new_words) else: return seed.WordNestMenu(item.arg) async def pick_new_seed(menu, label, item): import seed return await seed.make_new_wallet(item.arg) async def new_from_dice(menu, label, item): import seed return await seed.new_from_dice(item.arg) async def any_active_duress_ux(): from trick_pins import tp tp.reload() # if TPs are hidden this msg will not be shown if any(tp.get_duress_pins()): await ux_show_story('You have one or more duress wallets defined ' 'under Trick PINs. Please empty them, and clear ' 'associated Trick PINs before continuing.') return True return False async def convert_ephemeral_to_master(*a): import seed from stash import bip39_passphrase words = settings.get("words", True) _type = 'BIP-39 passphrase' if bip39_passphrase else 'temporary seed' msg = 'Convert currently used %s to master seed. Old master seed' % _type if words or bip39_passphrase: msg += ' words themselves are erased forever, ' else: msg += ' is erased forever, ' msg += ('and its settings blanked. This action is destructive ' 'and may affect funds, if any, on old master seed. ' 'Make sure all duress wallets associated with previous ' 'seed are deleted, otherwise they will be carried forward ' 'without being properly generated from new master seed. ' 'Saved temporary seed settings and Seed Vault are lost. ') if bip39_passphrase: msg += ('BIP-39 passphrase ' 'is captured during this process and will be in effect ' 'going forward, but the passphrase itself is erased ' 'and unrecoverable. ') if not words: msg += 'The resulting wallet cannot be used with any other passphrase. ' msg += 'A reboot is part of this process. ' msg += 'PIN code, and %s funds are not affected.' % _type if not await ux_confirm(msg, confirm_key='4'): return await ux_aborted() # settings.save is part of re-building fs await seed.remember_ephemeral_seed() await login_now() async def clear_seed(*a): # Erase the seed words, and private key from this wallet! # This is super dangerous for the customer's money. import seed # in hobble mode, they cannot reach duress wallets and/or maybe we don't # want to reveal them? So don't block them based on that. if not pa.hobbled_mode: if await any_active_duress_ux(): return await ux_aborted() if not await ux_confirm('Wipe seed words and reset wallet. ' 'All funds will be lost. ' 'You better have a backup of the seed words. ' 'All settings like multisig wallets are also wiped. ' 'Saved temporary seed settings and Seed Vault are lost. ' 'Trick PINs are also completely removed.'): return await ux_aborted() if not await ux_confirm('''Are you REALLY sure though???\n\n\ This action will certainly cause you to lose all funds associated with this wallet, \ unless you have a backup of the seed words and know how to import them into a \ new wallet.''', 'AGAIN...', confirm_key='4'): return await ux_aborted() # clear all trick PINs from SE2 from trick_pins import tp tp.clear_all() # clear settings, address cache, settings from tmp seeds / seedvault seeds from files import wipe_flash_filesystem wipe_flash_filesystem(False) seed.clear_seed() # NOT REACHED -- reset happens def render_master_secrets(mode, raw, node): # Render list of words, or XPRV / master secret to text. import stash, chains c = chains.current_chain() qr_alnum = False if mode == 'words': import bip39 words = bip39.b2a_words(raw).split(' ') # This optimization make the QR very nice, and space for # all the words too qr = ' '.join(w[0:4] for w in words) qr_alnum = True title = 'Seed words (%d):' % len(words) msg = "" if not version.has_qwerty: msg += title + "\n" title = None msg += ux_render_words(words) if stash.bip39_passphrase: msg += '\n\nBIP-39 Passphrase:\n *****' if node: msg += '\n\nSeed+Passphrase:\n%s' % c.serialize_private(node) elif mode == 'xprv': title = "Extended Private Key" if version.has_qwerty else None msg = c.serialize_private(node) qr = msg elif mode == 'master': title = "Master Secret" if version.has_qwerty else None msg = '%d bytes:\n\n' % len(raw) qr = str(b2a_hex(raw), 'ascii') msg += qr else: raise ValueError(mode) return title, msg, qr, qr_alnum async def view_seed_words(*a): if not await ux_confirm('The next screen will show the seed words' ' (and if defined, your BIP-39 passphrase).' '\n\nAnyone with knowledge of those words ' 'can control all funds in this wallet.'): return import stash from glob import dis, NFC dis.fullscreen("Wait...") dis.busy_bar(True) # preserve old UI where we show words + passphrase # instead of just calculated seed + passphrase = extended privkey # new: calculated xprv is now also shown for BIP39 passphrase wallet raw = mode = None if stash.bip39_passphrase: # get main secret - bypass tmp with stash.SensitiveValues(bypass_tmp=True, enforce_delta=True) as sv: assert sv.mode == "words" raw = sv.raw[:] mode = sv.mode stash.SensitiveValues.clear_cache() with stash.SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv: dis.busy_bar(False) title, msg, qr, qr_alnum = render_master_secrets(mode or sv.mode, raw or sv.raw, sv.node) esc = "1" if not version.has_qwerty: msg += '\n\nPress (1) to view as QR Code' if NFC: msg += ", (3) to share via NFC" esc += "3" msg += "." while 1: ch = await ux_show_story(msg, title=title, sensitive=True, escape=esc, hint_icons=KEY_QR+(KEY_NFC if NFC else '')) if ch in '1'+KEY_QR: from ux import show_qr_code await show_qr_code(qr, qr_alnum, is_secret=True) continue elif NFC and (ch in '3'+KEY_NFC): await NFC.share_text(qr, is_secret=True) continue break stash.blank_object(qr) stash.blank_object(msg) stash.blank_object(raw) async def export_seedqr(*a): # see standard: import bip39, stash if not await ux_confirm('The next screen will show the seed words in a QR code.' '\n\nAnyone with knowledge of those words ' 'can control all funds in this wallet.'): return from glob import dis dis.fullscreen("Wait...") dis.busy_bar(True) # Note: cannot reach this menu item if no words. If they are tmp, that's cool. with stash.SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv: if sv.mode != 'words': raise ValueError(sv.mode) words = bip39.b2a_words(sv.raw).split(' ') dis.busy_bar(False) qr = encode_seed_qr(words) del words from ux import show_qr_code await show_qr_code(qr, True, msg="SeedQR", is_secret=True) stash.blank_object(qr) async def version_migration(): # Handle changes between upgrades, and allow downgrades when possible. # - long term we generally cannot delete code from here, because we # never know when a user might skip a bunch of intermediate versions # Data migration issue: # - "login countdown" feature now stored elsewhere [mk3] had_delay = settings.get('lgto', 0) if had_delay: from nvstore import SettingsObject settings.remove_key('lgto') s = SettingsObject.prelogin() s.set('lgto', had_delay) s.save() del s # Disable vdisk so it is off by default until re-enabled, after # version 5.0.6 is installed settings.remove_key('vdsk') async def version_migration_prelogin(): # same, but for setting before login # these have moved into SE2 for Mk4 and so can be removed for n in [ 'cd_lgto', 'cd_mode', 'cd_pin' ]: settings.remove_key(n) async def start_login_sequence(): # Boot up login sequence here. # # - easy to brick units here, so catch and ignore errors where possible/appropriate # from ux import idle_logout, ux_login_countdown from glob import dis from imptask import IMPT if pa.is_blank(): # Blank devices, with no PIN set all, can continue w/o login goto_top_menu() return # data migration on settings that are used pre-login try: await version_migration_prelogin() except: pass if version.has_battery: from battery import batt_idle_logout IMPT.start_task('b-idle', batt_idle_logout()) # maybe show a nickname before we do anything try: nickname = settings.get('nick', None) if nickname: await show_nickname(nickname) except: pass # Allow impatient devs and crazy people to skip the PIN guess = settings.get('_skip_pin', None) if guess is not None: try: dis.fullscreen("Skip PIN...") pa.setup(guess) pa.login() except: pass # If that didn't work, or no skip defined, force # them to login successfully. sp_unlock = False try: from trick_pins import tp # Get a PIN and try to use it to login # - does warnings about attempt usage counts await block_until_login() sp_unlock = tp.was_sp_unlock() if sp_unlock: # Trying to unlock spending policy: ask for main PIN next. await ux_show_story("Spending Policy Unlock: Please provide Main PIN next.") pa.reset() await block_until_login() # we don't really know if that was the Main PIN (could easily be the bypass # PIN again) and if it's a duress wallet, that's cool... # Do we need to do countdown delay? (real or otherwise) # - wiping has already occurred if that was selected by trick details # - delay is variable, stored in tc_arg delay = tp.was_countdown_pin() # Maybe they do know the right PIN, but always do a delay anyway, because they wanted that if not delay: delay = settings.get('lgto', 0) if delay: # kill some time, with countdown, and get "the" PIN again for real login pa.reset() await ux_login_countdown(delay * (60 if not version.is_devmode else 1)) # keep it simple for Mk4+: just challenge again for any PIN # - if it's the same countdown pin, it will be accepted and they # get in (as most trick pins would do) await block_until_login() except BaseException as exc: # Robustness: any logic errors/bugs in above will brick the Coldcard # even for legit owner, since they can't login. So try to recover, when it's # safe to do so. Remember the bootrom checks PIN on every access to # the secret, so "letting" them past this point is harmless if they don't know # the true pin. # sys.print_exception(exc) if not pa.is_successful(): raise # Successful login... # Must re-read settings after login dis.fullscreen("Startup...") settings.set_key() settings.load(dis) # handle upgrades/downgrade issues try: await version_migration() except: pass # Maybe insist on the "right" microSD being already installed? try: from pwsave import MicroSD2FA MicroSD2FA.enforce_policy() except: pass # apply the hobbling for the spending policy, if appropriate try: from ccc import sssp_spending_policy, sssp_word_challenge if sp_unlock and sssp_spending_policy('words'): # challenge them also for first and last seed word! (will reboot on fail) await sssp_word_challenge() dis.fullscreen("Startup...") if sp_unlock: # Disable spending policy going forward; user has to re-enable. pa.hobbled_mode = False sssp_spending_policy('en', set_value=False) else: # normal entry mode, but might have policy enabled, if so enable it now. pa.hobbled_mode = sssp_spending_policy('en') except: pass # implement idle timeout now that we are logged-in IMPT.start_task('idle', idle_logout()) # Populate xfp/xpub values, if missing. # - can happen for first-time login of duress wallet # - may indicate lost settings, which we can easily recover from # - these values are important to USB protocol if not (settings.get('xfp', 0) and settings.get('xpub', 0)) and not pa.is_secret_blank(): try: import stash # Recalculate xfp/xpub values (depends both on secret and chain) with stash.SensitiveValues() as sv: sv.capture_xpub() except Exception as exc: # just in case, keep going; we're not useless and this # is early in boot process print("XFP save failed: %s" % exc) dis.draw_status(xfp=settings.get('xfp')) # If HSM policy file is available, offer to start that, # **before** the USB is even enabled. if version.supports_hsm: # do not offer HSM if wallet is blank -> HSM needs secret if not pa.is_secret_blank(): try: import hsm, hsm_ux if hsm.hsm_policy_available(): settings.put("hsmcmd", True) ar = await hsm_ux.start_hsm_approval(usb_mode=False, startup_mode=True) if ar: await ar.interact() except: pass if pa.is_deltamode(): # pretend Secure Notes & Passwords is disabled # pretend SeedVault is disabled try: settings.remove_key("secnap") settings.master_set("seedvault", False) except: pass from glob import hsm_active if version.has_nfc and settings.get('nfc', 0) and not hsm_active: # Maybe allow NFC now import nfc nfc.NFCHandler.startup() if settings.get('vidsk', 0) and not hsm_active: # Maybe start virtual disk import vdisk vdisk.VirtDisk() # Allow USB protocol, now that we are auth'ed if not settings.get('du', 0): from usb import enable_usb enable_usb() async def restore_main_secret(*a): from glob import dis from seed import restore_to_main_secret, in_seed_vault escape = None msg = "Restore main wallet and its settings?\n\n" if not in_seed_vault(pa.tmp_value): msg += ( "Press %s to forget current temporary seed " "settings, or press (1) to save & keep " "those settings if same seed is later restored." % OK ) escape = "1" ch = await ux_show_story(msg, escape=escape) if ch == "x": return dis.fullscreen("Working...") ps = True if escape and (ch == "y"): ps = False await restore_to_main_secret(preserve_settings=ps) goto_top_menu() def make_top_menu(): from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu, HobbledTopMenu from glob import hsm_active, settings from pincodes import pa if hsm_active: from hsm_ux import hsm_ux_obj m = hsm_ux_obj elif version.is_factory_mode: m = MenuSystem(FactoryMenu) elif pa.is_blank(): # let them play a little before picking a PIN first time m = MenuSystem(VirginSystem, should_cont=lambda: pa.is_blank()) else: assert pa.is_successful(), "nonblank but wrong pin" if pa.has_secrets(): # let them do a few things, but not all the things, when "hobbled" _cls = HobbledTopMenu[:] if pa.hobbled_mode else NormalSystem[:] if pa.tmp_value or settings.get("hmx", False): active_xfp = settings.get("xfp", 0) sl, sr = ("[", "]") if pa.tmp_value else ("<", ">") if active_xfp: ui_xfp = sl + xfp2str(active_xfp) + sr _cls.insert(0, MenuItem(ui_xfp, f=ready2sign)) if pa.tmp_value: _cls.append(MenuItem("Restore Master", f=restore_main_secret, shortcut='m')) else: _cls = EmptyWallet m = MenuSystem(_cls) return m def goto_top_menu(first_time=False): # Start/restart menu system m = make_top_menu() the_ux.reset(m) if first_time and not pa.is_secret_blank(): # guide new user thru some setup stuff from ftux import FirstTimeUX the_ux.push(FirstTimeUX()) return m SENSITIVE_NOT_SECRET = ''' The file created is sensitive--in terms of privacy--but should not \ compromise your funds directly.''' PICK_ACCOUNT = '\n\nPress %s to continue. Press (1) to enter a non-zero account number.' % OK async def dump_summary(*A): # save addresses, and some other public details into a file if not await ux_confirm('''\ Saves a text file with a summary of the *public* details \ of your wallet. For example, this gives the XPUB (extended public key) \ that you will need to import other wallet software to track balance.''' + SENSITIVE_NOT_SECRET): return # pick a semi-random file name, save it. await make_summary_file() async def export_xpub(label, _2, item): # provide bare xpub in a QR/NFC for import into simple wallets. import chains, glob, stash from ux import show_qr_code chain = chains.current_chain() acct = 0 slip132 = False # non-slip is default from Oct 2024 # decode menu code => standard derivation mode = item.arg if mode == -1: # XFP shortcut xfp = xfp2str(settings.get('xfp', 0)) await show_qr_code(xfp, True) return elif mode == 0: path = "m" addr_fmt = AF_CLASSIC else: remap = {44:0, 49:1, 84:2}[mode] _, path, addr_fmt = chains.CommonDerivations[remap] path = path.format(account=acct, coin_type=chain.b44_cointype, change=0, idx=0)[:-4] while 1: msg = 'Show QR of the XPUB for path:\n\n%s\n\n' % path esc = "" if path != "m": esc += "1" msg += "Press (1) to select account other than %s." % (acct or "zero") if addr_fmt != AF_CLASSIC: esc += "2" slp_af = addr_fmt if slip132: slp_af = AF_CLASSIC slp = chain.slip132[slp_af].hint + "pub" msg += " Press (2) to show %s %s." % ( slp, "(BIP-32)" if slip132 else "(SLIP-132)" ) if glob.NFC: if version.has_qwerty: esc += KEY_NFC key_hint = KEY_NFC else: esc += "3" key_hint = "(3)" msg += " Press %s to share via NFC. " % key_hint ch = await ux_show_story(msg, escape=esc) if ch == 'x': return if ch == "2": slip132 = not slip132 continue if ch == '1': acct = await ux_enter_bip32_index('Account Number:') if acct is None: continue pth_split = path.split("/") pth_split[-1] = ("%dh" % acct) path = "/".join(pth_split) continue # assume zero account if not picked path = path.format(acct=acct) from glob import dis dis.fullscreen('Wait...') # render xpub/ypub/zpub with stash.SensitiveValues() as sv: node = sv.derive_path(path) if path != 'm' else sv.node xpub = chain.serialize_public(node, addr_fmt if slip132 else AF_CLASSIC) from ownership import OWNERSHIP OWNERSHIP.note_wallet_used(addr_fmt, acct) if glob.NFC and ch in '3'+KEY_NFC: await glob.NFC.share_text(xpub) else: await show_qr_code(xpub, False) def electrum_export_story(noun="Electrum", background=False): # saves memory being in a function return ('''\ This saves a skeleton %s wallet file. \ You can then open that file in the wallet without ever connecting this Coldcard to a computer.\n ''' % noun + (background or 'Choose an address type for the wallet on the next screen.'+PICK_ACCOUNT) + SENSITIVE_NOT_SECRET) async def electrum_skeleton(a, b, item): # save xpub, and some other public details into a file: NOT MULTISIG title = item.arg fname_pat = "new-%s.json" % title.lower() ch = await ux_show_story(electrum_export_story(title), escape='1') acct = 0 if ch == '1': acct = await ux_enter_bip32_index('Account Number:') if (ch not in '1y') or acct is None: return rv = [ MenuItem(chains.addr_fmt_label(af), f=electrum_skeleton_step2, arg=(af, acct, title, fname_pat)) for af in chains.SINGLESIG_AF ] the_ux.push(MenuSystem(rv)) def ss_descriptor_export_story(addition="", background="", acct=True): # saves memory being in a function return ("This saves a ranged xpub descriptor" + addition + background + (PICK_ACCOUNT if acct else "") + SENSITIVE_NOT_SECRET) async def ss_descriptor_skeleton(_0, _1, item): # Export of descriptor data (wallet) addition, f_pattern = "", "descriptor.txt" int_ext = direct_way = None allowed_af = chains.SINGLESIG_AF if item.arg: int_ext, allowed_af, ll, f_pattern, direct_way = item.arg addition = " for " + ll acct = 0 if not direct_way: ch = await ux_show_story(ss_descriptor_export_story(addition), escape='1') if ch == '1': acct = await ux_enter_bip32_index('Account Number:', unlimited=True) if (ch not in '1y') or acct is None: return if int_ext is None: ch = await ux_show_story( "To export receiving and change descriptors in one descriptor " "(<0;1> notation) press %s, press (1) to export " "receiving and change descriptors separately." % OK, escape='1') if ch == "x": return int_ext = False if ch == "1" else True if len(allowed_af) == 1: await make_descriptor_wallet_export(allowed_af[0], acct, int_ext=int_ext, fname_pattern=f_pattern, direct_way=direct_way) else: rv = [ MenuItem(chains.addr_fmt_label(af), f=descriptor_skeleton_step2, arg=(af, acct, int_ext, f_pattern, direct_way)) for af in allowed_af ] the_ux.push(MenuSystem(rv)) async def key_expression_skeleton_step2(_1, _2, item): # pick a semi-random file name, render and save it. orig_path, addr_fmt = item.arg await make_key_expression_export(orig_path, addr_fmt) async def key_expression_skeleton(_0, _1, item): # Export key expression -> [xfp/d/e/r]xpub acct = 0 ch = await ux_show_story("This saves a extended key expression." + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1') if ch == '1': acct = await ux_enter_bip32_index('Account Number:', unlimited=True) if (ch not in '1y') or acct is None: return # element on 2nd index is address format for signed exports # if multisig key use p2pkh todo = [ ("Segwit P2WPKH", "m/84h/%dh/%dh", AF_P2WPKH), ("Classic P2PKH", "m/44h/%dh/%dh", AF_CLASSIC), ("P2SH-Segwit", "m/49h/%dh/%dh", AF_P2WPKH_P2SH), ("Multi P2WSH", "m/48h/%dh/%dh/2h", AF_CLASSIC), ("Multi P2SH-P2WSH", "m/48h/%dh/%dh/1h", AF_CLASSIC), ] from address_explorer import KeypathMenu async def doit(*a): return KeypathMenu(ranged=False, done_fn=make_key_expression_export) ct = chains.current_chain().b44_cointype rv = [ MenuItem(label, f=key_expression_skeleton_step2, arg=(orig_der % (ct, acct), af)) for label, orig_der, af in todo ] rv += [ MenuItem("Custom Path", menu=doit) ] the_ux.push(MenuSystem(rv)) async def samourai_post_mix_descriptor_export(*a): name = "POST-MIX" post_mix_acct_num = 2147483646 await samourai_account_descriptor(name, post_mix_acct_num) async def samourai_pre_mix_descriptor_export(*a): name = "PRE-MIX" pre_mix_acct_num = 2147483645 await samourai_account_descriptor(name, pre_mix_acct_num) # async def samourai_bad_bank_descriptor_export(*a): # name = "PRE-MIX" # pre_mix_acct_num = 2147483644 # await samourai_account_descriptor(name, pre_mix_acct_num) async def samourai_account_descriptor(name, account_num): ch = await ux_show_story( ss_descriptor_export_story( addition=" for Samourai %s account." % name, acct=False) ) if ch != 'y': return fn_pattern = "samourai-%s.txt" % name.lower() await make_descriptor_wallet_export(AF_P2WPKH, account_num, fname_pattern=fn_pattern) async def descriptor_skeleton_step2(_1, _2, item): # pick a semi-random file name, render and save it. addr_fmt, account_num, int_ext, f_pattern, dw = item.arg await make_descriptor_wallet_export(addr_fmt, account_num, int_ext=int_ext, fname_pattern=f_pattern, direct_way=dw) async def bitcoin_core_skeleton(*A): # save output descriptors into a file # - user has no choice, it's going to be bech32 with m/84'/{coin_type}'/0' path ch = await ux_show_story('''\ This saves commands and instructions into a file, including the public keys (xpub). \ You can then run the commands in Bitcoin Core's console window, \ without ever connecting this Coldcard to a computer.\ ''' + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape='1') acct = 0 if ch == '1': acct = await ux_enter_bip32_index('Account Number:') if (ch not in '1y') or acct is None: return # no choices to be made, just do it. await make_bitcoin_core_wallet(acct) async def electrum_skeleton_step2(_1, _2, item): # pick a semi-random file name, render and save it. addr_fmt, account_num, title, fname_pat = item.arg await export_contents(title + " wallet", lambda: generate_electrum_wallet(addr_fmt, account_num), fname_pat, is_json=True) async def _generic_export(prompt, label, f_pattern): # like the Multisig export, make a single JSON file with # basically all useful XPUB's in it. ch = await ux_show_story(prompt + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape="1") acct = 0 if ch == '1': acct = await ux_enter_bip32_index('Account Number:') if (ch not in '1y') or acct is None: return await export_contents(label, lambda: generate_generic_export(acct), f_pattern, is_json=True) async def generic_skeleton(*A): # like the Multisig export, make a single JSON file with # basically all useful XPUB's in it. prompt = '''\ Saves JSON file, with XPUB values that are needed to watch typical \ single-signer UTXO associated with this Coldcard.''' await _generic_export(prompt, 'Generic Export', 'coldcard-export.json') async def named_generic_skeleton(menu, label, item): name = item.arg # make a single JSON file with basically all useful XPUB's in it. # identical to generic_skeleton but with different story and filename. prompt = ('This saves a JSON file to use with %s Wallet. ' 'Works for both single signature and multisig wallets.') % name await _generic_export(prompt, '%s Wallet' % name, '%s-export.json' % name.lower()) async def wasabi_skeleton(*A): # save xpub, and some other public details into a file # - user has no choice, it's going to be bech32 with m/84'/0'/0' path ch = await ux_show_story('''\ This saves a skeleton Wasabi wallet file. \ You can then open that file in Wasabi without ever connecting this Coldcard to a computer.\ ''' + SENSITIVE_NOT_SECRET) if ch != 'y': return # no choices to be made, just do it. await export_contents('Wasabi wallet', lambda: generate_wasabi_wallet(), 'new-wasabi.json', is_json=True) async def unchained_capital_export(*a): # they were using our airgapped export, and the BIP-45 path from that # ch = await ux_show_story('''\ This saves multisig XPUB information required to setup on the Unchained platform. \ ''' + PICK_ACCOUNT + SENSITIVE_NOT_SECRET, escape="1") acct = 0 if ch == '1': acct = await ux_enter_bip32_index('Account Number:') if (ch not in '1y') or acct is None: return xfp = xfp2str(settings.get('xfp', 0)) fname = 'unchained-%s.json' % xfp await export_contents('Unchained', lambda: generate_unchained_export(acct), fname, is_json=True) async def backup_everything(*A): # save everything, using a password, into single encrypted file, typically on SD import backups await backups.make_complete_backup() async def verify_backup(*A): # check most recent backup is "good" # read 7z header, and measure checksums import backups fn = await file_picker(suffix='.7z', max_size=backups.MAX_BACKUP_FILE_SIZE) if not fn: return # do a limited CRC-check over encrypted file await backups.verify_backup_file(fn) async def import_extended_key_as_secret(extended_key, ephemeral, origin=None): try: import seed if ephemeral: await seed.set_ephemeral_seed_extended_key(extended_key, origin=origin) else: await seed.set_seed_extended_key(extended_key) except ValueError: msg = ("Sorry, wasn't able to find a valid extended private key to import. " "It should be at the start of a line, and probably starts with 'xprv'.") await ux_show_story(title="FAILED", msg=msg) except Exception as e: await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) async def import_xprv(_1, _2, item): # read an XPRV from a text file and use it. from glob import NFC extended_key = None label = "extended private key" ephemeral = item.arg if not ephemeral: assert pa.is_secret_blank() # "must not have secret" def contains_xprv(fname): # just check if likely to be valid; not full check try: with open(fname, 'rt') as fd: for ln in fd: # match tprv and xprv, plus y/zprv etc if 'prv' in ln: return True return False except OSError: # directories? return False choice = await import_export_prompt("%s file" % label, is_import=True) if choice == KEY_CANCEL: return elif choice == KEY_NFC: extended_key = await NFC.read_extended_private_key() if not extended_key: # failed to get any data - exit # error already displayed in nfc.py return elif choice == KEY_QR: from ux_q1 import QRScannerInteraction extended_key = await QRScannerInteraction.scan('Scan XPRV from a QR code') if not extended_key: # press pressed CANCEL return else: # only get here if NFC was not chosen # pick a likely-looking file. fn = await file_picker(suffix='.txt', min_size=50, max_size=2000, taster=contains_xprv, none_msg="Must contain " + label + ".", **choice) if not fn: return with CardSlot(readonly=True, **choice) as card: with open(fn, 'rt') as fd: for ln in fd.readlines(): if 'prv' in ln: extended_key = ln break await import_extended_key_as_secret(extended_key, ephemeral, origin='Imported XPRV') # not reached; will do reset. async def need_clear_seed(*a): await ux_show_story('''\ You must clear the wallet seed before restoring a backup because it replaces \ the seed value and the old seed would be lost.\n\n\ Visit the advanced menu and choose 'Destroy Seed'.''') async def restore_backup(a, b, item): # normal word based imports (tmp or master depending on item.arg) fn = await file_picker(suffix=".7z") if fn: import backups await backups.restore_complete(fn, item.arg, True) async def restore_backup_dev(*a): # used ONLY for Restore Bkup in I Am Developer fn = await file_picker(suffix=[".7z", ".txt"]) if fn: words = False if fn[-3:] == ".7z" else None import backups await backups.restore_complete(fn, not pa.is_secret_blank(), words) async def bkpw_override(*A): # allows user to: # 1.) manually set bkpw # 2.) remove existing bkpw setting # 3.) view current active bkpw # - some truncation of titles here on Mk4, # which is okay because re-using strings to save space. from backups import bkpw_min_len if pa.is_secret_blank(): return wipe_if_deltamode() while True: pwd = settings.get("bkpw", None) msg = ("Password used to encrypt COLDCARD backup files." "\n\nPress (0) to change backup password") esc = "0" if pwd: esc += "12" msg += ", (1) to forget current password, (2) to show current active backup password." else: msg += "." ch = await ux_show_story(title="BKPW Override", msg=msg, escape=esc) if ch == "x": return elif ch == "1": if await ux_confirm("Delete current stored password?"): settings.remove_key("bkpw") settings.save() await ux_dramatic_pause("Deleted.", 2) elif ch == "2": if await ux_confirm('The next screen will show current active backup password.' '\n\nAnyone with knowledge of the password will ' 'be able to decrypt your backups.'): await ux_show_story(pwd, title="Your Backup Password") elif ch == "0": if version.has_qwerty: from notes import get_a_password npwd = await get_a_password(pwd, min_len=bkpw_min_len) else: npwd = await ux_input_text(pwd, prompt="Your Backup Password", min_len=bkpw_min_len, max_len=128) if (npwd is None) or (npwd == pwd): continue settings.set('bkpw', npwd) settings.save() await ux_dramatic_pause("Saved.", 2) async def wipe_filesystem(*A): if not await ux_confirm('''\ Erase internal filesystem and rebuild it. Resets contents of internal flash area \ used for settings, address search cache, and HSM config file. Does not affect funds, \ or seed words but will reset settings used with other temporary seeds & BIP-39 passphrases. \ Does not affect MicroSD card, if any.''', confirm_key="4"): return from files import wipe_flash_filesystem wipe_flash_filesystem() async def nuke_device(*a): if not await ux_confirm("Wipe Seed & Brick device? This will wipe the seed, purge" " all related settings, and makes ewaste from this device."): return if not await ux_confirm("Brick device?\n\nBy design, there is no way to reset or recover" " the secure element, and its contents become forever inaccessible.", confirm_key="1"): return import callgate callgate.fast_brick() # NOT REACHED async def wipe_vdisk(*A): if not await ux_confirm('''\ Erases and reformats shared RAM disk. This is a secure erase that blanks every byte.'''): return import glob await glob.VD.wipe_disk() async def wipe_sd_card(*A): if not await ux_confirm('''\ Erases and reformats MicroSD card. This is not a secure erase but more of a quick format.'''): return from files import wipe_microsd_card wipe_microsd_card() async def qr_share_file(_1, _2, item): # Pick file from SD card and share as (BB)Qr from files import CardSlot, CardMissingError, needs_microsd from export import export_by_qr force_bbqr = item.arg def is_suitable(fname): f = fname.lower() return f.endswith('.psbt') or f.endswith('.txn') \ or f.endswith('.txt') or f.endswith(".json") or fname.endswith(".sig") try: while 1: txid = None fn = await file_picker(min_size=10, max_size=MAX_TXN_LEN, taster=is_suitable) if not fn: return basename = fn.split('/')[-1] ext = fn.split('.')[-1].lower() try: with CardSlot() as card: with open(fn, 'rb') as fp: data = fp.read() except CardMissingError: await needs_microsd() return if ext == "txn": tc = "T" txid = txid_from_fname(basename) if data[2:8] == b'000000': # it's a txn, and we wrote as hex data = data.decode() else: assert data[1:4] == bytes(3) data = b2a_hex(data).decode() elif data[0:5] == b'psbt\xff': tc = "P" elif data[0:6] in (b'cHNidP', b'707362'): tc = "U" data = data.decode().strip() elif ext in ('txt', 'json', 'sig'): tc = "U" if ext == "json": tc = "J" data = data.decode() else: raise ValueError(ext) await export_by_qr(data, txid, tc, force_bbqr=force_bbqr) except Exception as e: await ux_show_story( title="ERROR", msg="Failed to share file via QR.\n\n%s\n%s" % (e, problem_file_line(e)) ) async def nfc_share_file(*A): # Share txt, txn and PSBT files over NFC. from glob import NFC try: await NFC.share_file() except Exception as e: await ux_show_story( title="ERROR", msg="Failed to share file.\n\n%s\n%s" % (e, problem_file_line(e)) ) async def nfc_pushtx_file(*A): # Share a signed txn over NFC using PushTx technology # - requires a signed txn, perhaps from another system on SD card from glob import NFC try: await NFC.push_tx_from_file() except Exception as e: await ux_show_story(title="ERROR", msg="Failed to share file. %s" % str(e)) async def nfc_show_address(*A): from glob import NFC try: await NFC.address_show_and_share() except Exception as e: await ux_show_story( title="ERROR", msg="Failed to show address.\n\n%s\n%s" % (e, problem_file_line(e)) ) async def nfc_sign_msg(*A): # Receive data over NFC (text - follow sign txt file format) # User approval on device # Send signature RFC armored format back over NFC from glob import NFC try: await NFC.start_msg_sign() except Exception as e: await ux_show_story( title="ERROR", msg="Failed to sign message.\n\n%s\n%s" % (e, problem_file_line(e)) ) async def nfc_sign_verify(*A): # Receive armored data over NFC from glob import NFC try: await NFC.verify_sig_nfc() except Exception as e: await ux_show_story( title="ERROR", msg="Failed to verify signed message.\n\n%s\n%s" % (e, problem_file_line(e)) ) async def nfc_address_verify(*A): # Receive any random address (just the address) and find out if we own it. from glob import NFC try: await NFC.verify_address_nfc() except Exception as e: await ux_show_story( title="ERROR", msg='Address verification failed.\n\n%s\n%s' % (e, problem_file_line(e)) ) async def nfc_recv_ephemeral(*A): # Mk4: Share txt, txn and PSBT files over NFC. from glob import NFC try: await NFC.import_ephemeral_seed_words_nfc() except Exception as e: await ux_show_story( title="ERROR", msg="Failed to import temporary seed via NFC.\n\n%s\n%s" % (e, problem_file_line(e)) ) async def nfc_sign_psbt(*A): from glob import NFC try: await NFC.start_psbt_rx() except Exception as e: await ux_show_story( title="ERROR", msg='Failed to sign PSBT.\n\n%s\n%s' % (e, problem_file_line(e)) ) async def list_files(*A): fn = await file_picker(min_size=0) if not fn: return chk = sha256() try: with CardSlot() as card: with card.open(fn, 'rb') as fp: while 1: data = fp.read(1024) if not data: break chk.update(data) except CardMissingError: await needs_microsd() return from pincodes import pa digest = chk.digest() path, basename = fn.rsplit('/', 1) msg_base = 'SHA256(%s)\n\n' + B2A(digest) + '\n\nPress (1) to rename file, ' escape = "61" if pa.has_secrets(): msg_base += '(4) to sign file digest and export detached signature, ' escape += "4" msg_base += '(6) to delete.' while True: ch = await ux_show_story(msg_base % basename, escape=escape) if ch == "x": break if ch in '461': with CardSlot() as card: if ch == '6': card.securely_blank_file(fn) break elif ch == '1': new_basename = await ux_input_text(basename, max_len=32, min_len=3) if new_basename: try: # prohibit both slashes and space in filenames for s in "\/ ": assert s not in new_basename, "illegal char" uos.rename(path + "/" + basename, path + "/" + new_basename) basename = new_basename fn = path + "/" + basename # keep full path in sync (delete/sign use it) except Exception as e: await ux_show_story("Failed to rename the file. " + str(e), title="Failure") else: from msgsign import write_sig_file sig_nice = write_sig_file([(digest, fn)]) await ux_show_story("Signature file %s written." % sig_nice) return async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None, choices=None, none_msg=None, force_vdisk=False, slot_b=None, allow_batch=False, ux=True): # present a menu w/ a list of files... to be read # - optionally, enforce a max size, and provide a "tasting" function # - if (not ux), don't prompt, just do the search and return list # - if choices is provided; skip search process # - escape: allow these chars to skip picking process # - slot_b: None=>pick slot w/ card in it, or A if both. # - allow_batch: adds an "all of the above" choice: ("menu label", menu_handler) # - suffix argument MUST contain the dot (.txt), if list of suffixes, all MUST if suffix: # actually make it a list of "suffixes" if not isinstance(suffix, list): suffix = [suffix] assert all(s[0] == '.' for s in suffix) if choices is None: choices = [] try: with CardSlot(force_vdisk=force_vdisk, slot_b=slot_b, readonly=True) as card: sofar = set() for path in card.get_paths(): for fn, ftype, *var in uos.ilistdir(path): if ftype == 0x4000: # ignore subdirs continue if fn[0] == '.': # unix-style hidden files continue if suffix and not any(fn.lower().endswith(s) for s in suffix): # wrong suffix, skip continue full_fname = path + '/' + fn # Conside file size # sigh, OS/filesystem variations file_size = var[1] if len(var) == 2 else get_filesize(full_fname) if not (min_size <= file_size <= max_size): continue if taster is not None: try: yummy = taster(full_fname) except OSError: #print("fail: %s" % full_fname) yummy = False if not yummy: continue label = fn while label in sofar: # just the file name isn't unique enough sometimes? # - shouldn't happen anymore now that we don't support internal FS # - unless we do muliple paths label += path.split('/')[-1] + '/' + fn sofar.add(label) choices.append((label, path, fn)) except CardMissingError: # don't show anything if we're just gathering data if ux is not False: await needs_microsd() return None if ux is False: return choices if not choices: msg = 'No suitable files found. ' if none_msg: msg += none_msg if suffix: msg += '\n\nThe filename must end in: ' + ' OR '.join(suffix) msg += '\n\nMaybe insert (another) SD card and try again?' await ux_show_story(msg) return picked = [] async def clicked(_1,_2,item): picked.append('/'.join(item.arg)) the_ux.pop() choices.sort() items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices] if allow_batch and len(choices) > 1: # Allow an "all" selection label, funct = allow_batch items.insert(0, MenuItem(label, f=funct, arg=choices)) menu = MenuSystem(items) the_ux.push(menu) await menu.interact() return picked[0] if picked else None async def debug_assert(*a): assert False, "failed assertion" async def debug_bbqr_test(*a): # Q only: test BBQr w/ lots of data from ux_q1 import show_bbqr_codes from gpu_binary import BINARY await show_bbqr_codes('B', BINARY*3, 'GPU binary') async def debug_except(*a): print(34 / 0) async def check_firewall_read(*a): import uctypes ps = uctypes.bytes_at(0x7800, 32) # off the mark for mk4, but still valid test assert False # should not be reached async def bless_flash(*a): # make green LED turn on from glob import dis # impt: bugfix dis.bootrom_takeover() # do it pa.greenlight_firmware() # redraw our screen dis.busy_bar(False) # includes dis.show() def is_psbt(filename): if '-signed' in filename.lower(): # XXX problem: multi-signers? return False with open(filename, 'rb') as fd: taste = fd.read(10) if taste[0:5] == b'psbt\xff': return True if taste[0:10] == b'70736274ff': # hex-encoded return True if taste[0:6] == b'cHNidP': # base64-encoded return True return False async def _batch_sign(choices=None): if not choices: picked = await import_export_prompt("PSBTs", is_import=True, no_nfc=True, no_qr=True) if picked == KEY_CANCEL: return assert isinstance(picked, dict) choices = await file_picker(suffix='.psbt', min_size=50, ux=False, max_size=MAX_TXN_LEN, taster=is_psbt, **picked) if not choices: await ux_show_story("No PSBTs found. Need to have '.psbt' suffix.") return from auth import sign_psbt_file from ux import the_ux for label, path, fn in choices: ch = await ux_show_story("Sign %s ??\n\nPress %s to sign, (1) to skip this PSBT," " %s to quit and exit." % (fn, OK, X), escape="1") if ch == "x": break elif ch == "y": input_psbt = path + '/' + fn await sign_psbt_file(input_psbt) await sleep_ms(100) await the_ux.top_of_stack().interact() async def batch_sign(_1, _2, item): try: await _batch_sign(item.arg) except Exception as e: import sys await ux_show_story("FAILURE: batch sign failed\n\n" + problem_file_line(e)) async def ready2sign(*a): # Top menu choice of top menu! Signing! # - check if any signable in SD card, if so do it # - if no card, check virtual disk for PSBT # - if still nothing, then talk about USB connection from pincodes import pa from glob import NFC opt = {} # just check if we have candidates, no UI choices = await file_picker(suffix='.psbt', min_size=50, ux=False, max_size=MAX_TXN_LEN, taster=is_psbt) if pa.tmp_value: title = '[%s]' % xfp2str(settings.get('xfp')) else: title = None if not choices: msg = '''Coldcard is ready to sign spending transactions! Put the proposed transaction onto MicroSD card \ in PSBT format (Partially Signed Bitcoin Transaction) \ or upload a transaction to be signed \ from your desktop wallet software or command line tools.''' footnotes = ("You will always be prompted to confirm the details " "before any signature is performed.") # if we have only one SD card inserted, at this point, we know no PSBTs on them # as above file_picker already checked # if we have both inserted, A was already checked - so only care about B picked = await import_export_prompt("PSBT", is_import=True, intro=msg, footnotes=footnotes, slot_b_only=True, title=title) if isinstance(picked, dict): opt = picked # reset options to what was chosen by user choices = await file_picker(suffix='.psbt', min_size=50, ux=False, max_size=MAX_TXN_LEN, taster=is_psbt, **opt) if not choices: await ux_show_story('Unable to find any suitable files for this operation.' ' The filename must end in psbt.') return else: if NFC and picked == KEY_NFC: await NFC.start_psbt_rx() if picked == KEY_QR: await _scan_any_qr() return if len(choices) == 1: # single - skip the menu label,path,fn = choices[0] input_psbt = path + '/' + fn else: # multiples - ask which, and offer batch to sign them all input_psbt = await file_picker(choices=choices, allow_batch=("[Sign All]", batch_sign)) if not input_psbt: return # start the process from auth import sign_psbt_file await sign_psbt_file(input_psbt, **opt) async def sign_message_on_sd(*a): # Menu item: choose a file to be signed (as a short text message) # def is_signable(filename): if '-signed' in filename.lower(): return False with open(filename, 'rt') as fd: lines = fd.readlines() # min 1 line max 3 lines return 1 <= len(lines) <= 3 fn = await file_picker(suffix=['.txt', ".json"], min_size=2, max_size=500, taster=is_signable, none_msg=('Must be txt file with one msg line, optionally ' 'followed by a subkey derivation path on a second line ' 'and/or address format on third line. JSON msg signing ' 'format also supported')) if not fn: return # start the process from auth import sign_txt_file await sign_txt_file(fn) async def verify_sig_file(*a): def is_sig_file(filename): with open(filename, 'rt') as fd: line0 = fd.readline() if "SIGNED MESSAGE" in line0: return True return False fn = await file_picker(min_size=220, max_size=10000, taster=is_sig_file, none_msg='Must be file with ascii armor.') if not fn: return # start the process from msgsign import verify_txt_sig_file await verify_txt_sig_file(fn) async def main_pin_changer(*a): # Help them to change the main (true) PIN with appropriate warnings. # - the bootloader maybe lying to us about main vs trick pin # - what may look like just policy here, is in fact enforced by the bootrom code # from glob import dis from login import LoginUX from pincodes import BootloaderError, EPIN_OLD_AUTH_FAIL lll = LoginUX() title = 'Main PIN' msg = '''\ You will be changing the main PIN used to unlock your Coldcard. THERE IS ABSOLUTELY NO WAY TO RECOVER A FORGOTTEN PIN!\n Write it down.''' ch = await ux_show_story(msg, title=title) if ch != 'y': return async def incorrect_pin(): await ux_show_story('You provided an incorrect value for the existing PIN.', title='Wrong PIN') return args = {} # We need the existing pin, so prompt for that. lll.subtitle = 'Old ' + title old_pin = await lll.prompt_pin() if old_pin is None: return await ux_aborted() args['old_pin'] = old_pin.encode() # we can verify the main pin right away here. Be nice. if args['old_pin'] != pa.pin: return await incorrect_pin() while 1: lll.reset() lll.subtitle = "New " + title pin = await lll.get_new_pin() if pin is None: return await ux_aborted() from trick_pins import tp prob = tp.check_new_main_pin(pin) if prob: await ux_show_story(prob, title="Try Again") continue args['new_pin'] = pin.encode() break # install it. try: dis.fullscreen("Saving PIN...") dis.busy_bar(True) pa.change(**args) dis.busy_bar(False) except Exception as exc: dis.busy_bar(False) code = exc.args[1] if code == EPIN_OLD_AUTH_FAIL: # unlikely: but maybe we got tricked? return await incorrect_pin() else: return await ux_show_story("Unexpected low-level error: %s" % exc.args[0], title='Error') # Main pin is changed, and we use it lots, so update pa # - this step can be super slow with 608, unfortunately try: dis.fullscreen("Verify...") dis.busy_bar(True) pa.setup(args['new_pin']) if not pa.is_successful(): # typical: do need login, but if we just cleared the main PIN, # we cannot/need not login again pa.login() # Deltamode trick pins need to track main pin from trick_pins import tp tp.main_pin_has_changed(pa.pin.decode()) finally: dis.busy_bar(False) async def show_version(*a): # show firmware, bootload versions. import callgate, version from glob import NFC built, rel, *_ = version.get_mpy_version() bl = callgate.get_bl_version() bl_dev = dict(bl[1]).get('DEV', 0) chk = str(b2a_hex(callgate.get_bl_checksum(0))[-8:], 'ascii') se = '\n '.join(callgate.get_se_parts()) # exposed over USB interface: serial = version.serial_number() # this UID is exposed over NFC interface, but only when enabled and in active use if NFC: serial += '\n\nNFC UID:\n ' + NFC.get_uid().replace(':', '') hw = version.hw_label if not version.has_nfc: hw += ' (no NFC)' msg = '''\ Coldcard Firmware {rel} {built} Bootloader: {bl} {chk} Serial: {ser} Hardware: {hw} Secure Elements: {se} ''' msg = msg.format(rel=rel, built=built, bl=bl[0]+(' DEV' if bl_dev else''), chk=chk, se=se, ser=serial, hw=hw) if version.has_qr: from glob import SCAN, dis msg += '\nQR Scanner:\n %s\n' % (SCAN.version or 'missing') msg += '\nGPU:\n %s\n' % dis.gpu.get_version() await ux_show_story(msg) async def ship_wo_bag(*a): # Factory command: for dev and test units that have no bag number, and never will. ok = await ux_confirm('''Not recommended! DO NOT USE for units going to paying customers.''') if not ok: return import callgate from glob import dis from version import is_devmode failed = callgate.set_bag_number(b'NOT BAGGED') # 32 chars max if failed: await ux_dramatic_pause('FAILED', 30) else: # lock the bootrom firmware forever callgate.set_rdp_level(2) # bag number affects green light status (as does RDP level) dis.bootrom_takeover() pa.greenlight_firmware() dis.fullscreen('No Bag. DONE') callgate.show_logout(1) async def set_highwater(*a): # rarely? used command import callgate have = version.get_mpy_version()[0] ts = version.get_header_value('timestamp') hw = callgate.get_highwater() if hw == ts: await ux_show_story('''Current version (%s) already marked as high-water mark.''' % have) return ok = await ux_confirm('''Mark current version (%s) as the minimum, and prevent any downgrades below this version. Rarely needed as critical security updates will set this automatically.''' % have) if not ok: return rv = callgate.set_highwater(ts) # add error display here? meh. assert rv == 0, "Failed: %r" % rv async def start_hsm_menu_item(*a): from hsm_ux import start_hsm_approval await start_hsm_approval(sf_len=0, usb_mode=False) async def wipe_hsm_policy(*A): # deep in danger zone menu; no background story, nor confirmation # - sends them back to top menu, so that dynamic contents are fixed from hsm import hsm_delete_policy hsm_delete_policy() goto_top_menu() async def wipe_address_cache(*a): ok = await ux_confirm('''Clear cached addresses used in ownership search. Harmless to erase, just costs time.''') if not ok: return from ownership import OWNERSHIP OWNERSHIP.wipe_all() await ux_dramatic_pause("Cleared.", 3) async def wipe_ovc(*a): ok = await ux_confirm('''Clear history of segwit UTXO input values we have seen already. \ This data protects you against specific attacks. Use this only if certain a false-positive \ has occurred in the detection logic.''') if not ok: return import history history.OutptValueCache.clear() await ux_dramatic_pause("Cleared.", 3) async def change_usb_disable(dis): # user has disabled USB port (or re-enabled) import pyb cur = pyb.usb_mode() from usb import enable_usb, disable_usb if cur and dis: # usb enabled, but should not be now disable_usb() elif not cur and not dis: # USB disabled, but now should be enable_usb() async def usb_keyboard_emulation(enable): # just sets emu flag on and adds Entry Password into top menu # no USB switching at this point # - need to force reload of main menu, so it shows/hides new_top_menu = make_top_menu() the_ux.stack[0] = new_top_menu # top menu is always element 0 async def change_nfc_enable(enable): # NFC enable / disable from glob import NFC import nfc if not enable: if NFC: NFC.shutdown() else: nfc.NFCHandler.startup() async def change_virtdisk_enable(enable): # NOTE: enable can be 0,1,2 import glob, vdisk if bool(enable) == bool(glob.VD): # not a change in state, do nothing return if enable: # just showing up as new media is enough (MacOS) to make it show up vdisk.VirtDisk() assert glob.VD else: assert glob.VD glob.VD.shutdown() assert not glob.VD async def change_seed_vault(is_enabled): # user has changed seed vault enable/disable flag if (not is_enabled) and settings.master_get('seeds'): # problem: they still have some seeds... also this path blocks # disable from within a tmp seed settings.set('seedvault', 1) # restore it await ux_show_story("Please remove all seeds from the vault before disabling.") return goto_top_menu() async def change_which_chain(*a): # setting already changed, but reflect that value in other settings from glob import dis dis.fullscreen("Wait...") try: # update xpub stored in settings import stash with stash.SensitiveValues() as sv: sv.capture_xpub() except ValueError: # no secrets yet, not an error pass async def microsd_2fa(*a): # Feature: enforce special MicroSD being inserted at login time (a 2FA) from pwsave import MicroSD2FA if not settings.get('sd2fa'): msg = ('When enabled, this feature requires a specially prepared MicroSD card ' 'to be inserted during login process. After correct PIN is provided, ' 'if card slot is empty or unknown card present, the seed is wiped.') if version.num_sd_slots > 1: msg += ("\n\nIf multiple SD cards are present during login, make sure that" " authorized card is in the top slot (slot A).") ch = await ux_show_story(msg) if ch != 'y': return return MicroSD2FA.menu() async def keyboard_test(*a): # to aid keyboard testing/dev if version.has_qwerty: await ux_input_text('', max_len=128, scan_ok=True, confirm_exit=False, prompt='Keyboard Test', placeholder='(type whatever)') else: from ux_mk4 import ux_input_digits await ux_input_digits('') async def quick_nfc_test(*a): from selftest import test_nfc await test_nfc() async def clear_tested_flag(*a): # so can re-create first time power up in # factory case (direct to selftest) settings.remove_key('tested') settings.save() await reset_self() # # Q wrappers; these will be present, but are very short on mk4 # async def reflash_gpu(*a): from glob import dis await dis.gpu.reflash_gpu_ux() async def scan_any_qr(menu, label, item): expect_secret, tmp = item.arg await _scan_any_qr(expect_secret, tmp) async def _scan_any_qr(expect_secret=False, tmp=False): from ux_q1 import QRScannerInteraction x = QRScannerInteraction() try: await x.scan_anything(expect_secret=expect_secret, tmp=tmp) except Exception as e: await ux_show_story(msg="Failed to import from QR.\n\n%s\n%s" % (e, problem_file_line(e)), title="ERROR") PUSHTX_SUPPLIERS = [ # (label, URL) ('coldcard.com', 'https://coldcard.com/pushtx#'), # from https://github.com/mempool/mempool/pull/5132 ('mempool.space', 'https://mempool.space/pushtx#'), ] async def feature_requires_nfc(): # prompt them that it's need (iff not already enabled) # - return F if they decline if settings.get('nfc'): return True # force on NFC, so it works... but they can still turn it off later, etc. if not await ux_confirm("This feature requires NFC to be enabled. %s to enable." % OK): return False settings.set("nfc", 1) await change_nfc_enable(1) return True async def pushtx_setup_menu(*a): # let them pick a URL from menu to enable "pushtx" feature, and provide # some background, and even let them enter a custom URL. if not settings.get("ptxurl", False): # force a warning on them, unless they are already doing it. ch = await ux_show_story( "When this is enabled, immediately after transaction signing, you can " "tap any NFC-enabled phone on the COLDCARD and your newly-signed " "transaction will be immediately broadcast on the public network.\n\n" "You must choose a provider by URL here, or give your own URL. " "\n\nYour phone's IP address vs. transaction details could be linked by the service. " "Make sure your phone is not in airplane mode. Requires NFC.", title="PUSH TX", ) if ch != "y": return if not await feature_requires_nfc(): # they don't want to proceed return async def doit(menu, picked, xx_self): # using stock values, or Disable val = xx_self.arg[0] if val: settings.set('ptxurl', val) else: settings.remove_key('ptxurl') menu.chosen = picked menu.show() await sleep_ms(100) # visual feedback that we changed it the_ux.pop() async def edit_custom(menu, picked, xx_self): val = xx_self.arg[0] while 1: nv = await ux_input_text(val, confirm_exit=True, scan_ok=True, prompt="Enter URL") # cleanup? URL validation? if nv: prob = None if (not nv.startswith('http://')) and (not nv.startswith('https://')): prob = "Must start with http:// or https://." elif len(nv) < 12: prob = "Too short." elif nv[-1] not in '#?&': prob = "Final char must be # or ? or &." if prob: await ux_show_story(prob + " Try again.") val = nv continue break if nv: settings.set('ptxurl', nv) # force menu redraw m = await pushtx_setup_menu() the_ux.replace(m) else: settings.remove_key('ptxurl') the_ux.pop() was = settings.get("ptxurl", None) try: cur = [n for n, (l, u) in enumerate(PUSHTX_SUPPLIERS) if u == was][0] except IndexError: cur = None choices = [MenuItem(l, f=doit, arg=(u,)) for l,u in PUSHTX_SUPPLIERS] if was and cur is None: # they have a non-standard choice label = was.split("/")[2] # pull out domain (netloc) choices.append(MenuItem(label, f=edit_custom, arg=(was,))) cur = len(choices)-1 else: choices.append(MenuItem("Custom URL...", f=edit_custom, arg=('http',))) choices.append(MenuItem("Disable", f=doit, arg=(None,))) return MenuSystem(choices, chosen=cur) # EOF