firmware/shared/actions.py
2023-12-05 12:30:39 +01:00

2293 lines
72 KiB
Python

# (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
from uhashlib import sha256
from uasyncio import sleep_ms
from ubinascii import hexlify as b2a_hex
from utils import imported, pretty_short_delay, problem_file_line, import_prompt_builder
from utils import xfp2str, decrypt_tapsigner_backup, B2A, addr_fmt_label
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
from export import make_json_wallet, 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
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
from version import MAX_TXN_LEN
CLEAR_PIN = '999999-999999'
async def start_selftest(*args):
if len(args) and not version.is_factory_mode:
# called from inside menu, not directly
# - mk4 doesn't damage settings, only earlier marks
if not await ux_confirm('''Selftest may destroy settings on other profiles (not seeds). Requires MicroSD card and might have other consequences. Recommended only for factory.'''):
return await ux_aborted()
with imported('selftest') as st:
await st.start_selftest()
settings.save()
async def needs_primary():
# Standard msg shown if action can't be done w/o main PIN
await ux_show_story("Only the holder of the main PIN (not the secondary) can perform this function. Please start over with the main PIN.")
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.
\n
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:
https://
coldcardwallet
.com/legal
Press OK to accept terms and continue.""", 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
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())
if pa.is_secondary:
msg += '\n(Secondary wallet)\n'
if stash.bip39_passphrase:
msg += '\nBIP-39 passphrase is in effect.\n'
elif pa.tmp_value:
msg += '\nTemporary seed is in effect.\n'
bn = callgate.get_bag_number()
if bn:
msg += '\nShipping Bag:\n %s\n' % bn
if xpub:
msg += '\nPress (3) to show QR code of xpub.'
ch = await ux_show_story(msg, escape=('3' if xpub else None))
if ch == '3':
# 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
force_vdisk = item.arg
fn = await file_picker('Pick firmware image to use (.DFU)', 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:
offset, size = dfu_parse(fp)
# we also put a copy of special signed heaer at the end of the flash
# 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)
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(title)
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 menu import MenuSystem
from flow import EmptyWallet
return MenuSystem(EmptyWallet)
async def login_countdown(sec):
# Show a countdown, which may need to
# run for multiple **days**
from glob import dis
from display import FontSmall, FontLarge
from utime import ticks_ms, ticks_diff
# pre-render fixed parts
dis.clear()
y = 0
dis.text(None, y, 'Login countdown in', font=FontSmall); y += 14
dis.text(None, y, 'effect. Must wait:', font=FontSmall); y += 14
y += 5
dis.save()
st = ticks_ms()
while sec > 0:
dis.restore()
dis.text(None, y, pretty_short_delay(sec), font=FontLarge)
dis.show()
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)
async def block_until_login():
#
# Force user to enter a valid PIN.
# - or accept a bogus one and return T iff mk<4 and "countdown" pin used
#
from login import LoginUX
from ux import AbortInteraction
# 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 display import FontLarge, FontTiny, FontSmall
from ux import ux_wait_keyup
dis.clear()
if dis.width(nick, FontLarge) <= dis.WIDTH:
dis.text(None, 21, nick, font=FontLarge)
else:
dis.text(None, 27, nick, font=FontSmall)
dis.show()
await ux_wait_keyup()
async def pick_killkey(*a):
# Setting: kill seed sometimes (requires mk4)
if await ux_show_story('''\
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.''') != '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()
nick = s.get('nick', '')
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)
nn = nn.strip() if nn else None
s.set('nick', nn)
s.save()
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_b39_pw(menu, label, item):
if not settings.get('b39skip', False):
ch = await ux_show_story('''\
You may add a passphrase to your BIP-39 seed words. \
This creates an entirely new wallet, for every possible passphrase.
By default, the Coldcard uses an empty string as the passphrase.
On the next menu, you can enter a passphrase by selecting \
individual letters, choosing from the word list (recommended), \
or by typing numbers.
Please write down the fingerprint of all your wallets, so you can \
confirm when you've got the right passphrase. (If you are writing down \
the passphrase as well, it's okay to put them together.) There is no way for \
the Coldcard to know if your password is correct, and if you have it wrong, \
you will be looking at an empty wallet.
Limitations: 100 characters max length, ASCII \
characters 32-126 (0x20-0x7e) only.
OK to start.
X to go back. Or press (2) to hide this message forever.
''', escape='2')
if ch == '2':
settings.set('b39skip', True)
if ch == 'x':
return
import seed
return seed.PassphraseMenu()
async def start_seed_import(menu, label, item):
import seed
return seed.WordNestMenu(item.arg)
def pick_new_seed(menu, label, item):
import seed
return seed.make_new_wallet(item.arg)
def new_from_dice(menu, label, item):
import seed
return seed.new_from_dice(item.arg)
async def convert_ephemeral_to_master(*a):
import seed
from pincodes import pa
from stash import bip39_passphrase
if not pa.tmp_value:
await ux_show_story('You do not have an active temporary seed (including BIP-39 passphrase)'
' right now, so this command does little except forget the seed words.'
' It does not enhance security in any way.')
return
words = settings.get("words", True)
msg = 'Convert currently used '
msg += 'BIP-39 passphrase ' if bip39_passphrase else 'temporary seed '
msg += 'to main seed. '
if words or bip39_passphrase:
msg += 'Main seed words themselves are erased forever, '
else:
msg += 'Main seed is erased forever, '
msg += 'but effectively there is no other change. '
if bip39_passphrase:
msg += ('BIP-39 passphrase is currently in effect, its value '
'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. PIN code, and funds are not affected.'
if not await ux_confirm(msg):
return await ux_aborted()
await seed.remember_ephemeral_seed()
settings.save()
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
if pa.has_duress_pin():
await ux_show_story('Please empty the duress wallet, and clear '
'the duress PIN before clearing main seed.')
return
from trick_pins import tp
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 clearing main seed.')
return
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.'):
return await ux_aborted()
ch = await ux_show_story('''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.\n\nPress (4) to prove you read to the end of this message and accept all \
consequences.''', escape='4')
if ch != '4':
return await ux_aborted()
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
msg = 'Seed words (%d):\n' % len(words)
msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(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':
msg = c.serialize_private(node)
qr = msg
elif mode == 'master':
msg = '%d bytes:\n\n' % len(raw)
qr = str(b2a_hex(raw), 'ascii')
msg += qr
else:
raise ValueError(mode)
return msg, qr, qr_alnum
async def view_seed_words(*a):
import stash
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
from glob import dis
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) as sv:
if not sv.deltamode:
assert sv.mode == "words"
raw = sv.raw[:]
mode = sv.mode
stash.SensitiveValues.clear_cache()
with stash.SensitiveValues(bypass_tmp=False) as sv:
if sv.deltamode:
# give up and wipe self rather than show true seed values.
import callgate
callgate.fast_wipe()
dis.busy_bar(False)
msg, qr, qr_alnum = render_master_secrets(mode or sv.mode,
raw or sv.raw,
sv.node)
msg += '\n\nPress (1) to view as QR Code.'
while 1:
ch = await ux_show_story(msg, sensitive=True, escape='1')
if ch == '1':
from ux import show_qr_code
await show_qr_code(qr, qr_alnum)
continue
break
stash.blank_object(qr)
stash.blank_object(msg)
stash.blank_object(raw)
async def damage_myself():
# called when it's time to disable ourselves due to various
# features related to duress and so on
# - mk2 cannot do this
# - mk4 doesn't call this, done by bootrom
mode = settings.get('cd_mode', 0)
#['Brick', 'Final PIN', 'Test Mode']
if mode == 2:
# test mode, do no damage
return
from glob import dis
dis.fullscreen("Wait...")
dis.busy_bar(True)
if mode == 1:
# leave single attempt; careful!
# - always consume one attempt, regardless
todo = max(1, pa.attempts_left - 1)
else:
# brick ourselves, by consuming all PIN attempts
todo = pa.attempts_left
# do a bunch of failed attempts
pa.setup('hfsp', False)
for i in range(todo):
try:
pa.login()
except:
# expecting EPIN_AUTH_FAIL
pass
# Try to keep UX responsive? But callgate stuff blocks everything,
# so just go as fast as possible.
dis.busy_bar(False)
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
from glob import dis
import callgate
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
# 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.splash_text("Skip PIN...")
pa.setup(guess)
pa.login()
except: pass
# If that didn't work, or no skip defined, force
# them to login successfully.
try:
# Get a PIN and try to use it to login
# - does warnings about attempt usage counts
wants_countdown = await block_until_login()
# Do we need to do countdown delay? (real or otherwise)
delay = 0
# Mk4 approach:
# - wiping has already occured if that was picked
# - delay is variable, stored in tc_arg
from trick_pins import tp
delay = tp.was_countdown_pin()
# Maybe they do know the right PIN, but 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 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.
if not pa.is_successful():
raise
sys.print_exception(exc)
# Successful login...
# Must re-read settings after login
dis.splash_text("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 BaseException as exc:
# robustness: keep going!
sys.print_exception(exc)
# implement idle timeout now that we are logged-in
from imptask import IMPT
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)
# 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 version.has_nfc and settings.get('nfc', 0):
# Maybe allow NFC now
import nfc
nfc.NFCHandler.startup()
if settings.get('vidsk', 0):
# 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 OK to forget current temporary wallet "
"settings, or press (1) to save & keep "
"those settings if same seed is later restored."
)
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 menu import MenuSystem, MenuItem
from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu
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 not pa.is_secret_blank():
_cls = NormalSystem[:]
if pa.tmp_value:
active_xfp = settings.get("xfp", 0)
if active_xfp:
ui_xfp = "[" + xfp2str(active_xfp) + "]"
_cls.insert(0, MenuItem(ui_xfp, f=ready2sign))
_cls.append(MenuItem("Restore Master", f=restore_main_secret))
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 (1) to enter a non-zero account number.'''
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
# 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]
# always show SLIP-132 style, because defacto
show_slip132 = (addr_fmt != AF_CLASSIC)
while 1:
msg = '''Show QR of the XPUB for path:\n\n%s\n\n''' % path
if '{acct}' in path:
msg += "Press (1) to select account other than zero. "
if glob.NFC:
msg += "Press (3) to share via NFC. "
ch = await ux_show_story(msg, escape='13')
if ch == 'x': return
if ch == '1':
acct = await ux_enter_bip32_index('Account Number:') or 0
path = path.format(acct=acct)
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 ch == '3' and glob.NFC:
await glob.NFC.share_text(xpub)
else:
from ux import show_qr_code
await show_qr_code(xpub, False)
break
def electrum_export_story(background=False):
# saves memory being in a function
return ('''\
This saves a skeleton Electrum wallet file. \
You can then open that file in Electrum without ever connecting this Coldcard to a computer.\n
'''
+ (background or 'Choose an address type for the wallet on the next screen.'+PICK_ACCOUNT)
+ SENSITIVE_NOT_SECRET)
async def electrum_skeleton(*a):
# save xpub, and some other public details into a file: NOT MULTISIG
ch = await ux_show_story(electrum_export_story(), escape='1')
account_num = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
return
# pick segwit or classic derivation+such
from menu import MenuSystem, MenuItem
# Ordering and terminology from similar screen in Electrum. I prefer
# 'classic' instead of 'legacy' personallly.
rv = []
rv.append(MenuItem(addr_fmt_label(AF_CLASSIC), f=electrum_skeleton_step2,
arg=(AF_CLASSIC, account_num)))
rv.append(MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=electrum_skeleton_step2,
arg=(AF_P2WPKH_P2SH, account_num)))
rv.append(MenuItem(addr_fmt_label(AF_P2WPKH), f=electrum_skeleton_step2,
arg=(AF_P2WPKH, account_num)))
return MenuSystem(rv)
def ss_descriptor_export_story(addition="", background=None):
# saves memory being in a function
return ("This saves a ranged xpub descriptor" + addition
+ (background or
'. Choose descriptor and address type for the wallet on next screens.'+PICK_ACCOUNT)
+ SENSITIVE_NOT_SECRET)
async def ss_descriptor_skeleton(label, _, item):
# Export of descriptor data (wallet)
ch = await ux_show_story(ss_descriptor_export_story(), escape='1')
account_num = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:', unlimited=True) or 0
elif ch != 'y':
return
int_ext = True
ch = await ux_show_story(
"To export receiving and change descriptors in one descriptor (<0;1> notation) press OK, "
"press (1) to export receiving and change descriptors separately.", escape='1')
if ch == "1":
int_ext = False
elif ch != "y":
return
# pick segwit or classic derivation+such
from menu import MenuSystem, MenuItem
# Ordering and terminology from similar screen in Electrum. I prefer
# 'classic' instead of 'legacy' personallly.
rv = []
rv.append(MenuItem(addr_fmt_label(AF_CLASSIC), f=descriptor_skeleton_step2,
arg=(AF_CLASSIC, account_num, int_ext)))
rv.append(MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=descriptor_skeleton_step2,
arg=(AF_P2WPKH_P2SH, account_num, int_ext)))
rv.append(MenuItem(addr_fmt_label(AF_P2WPKH), f=descriptor_skeleton_step2,
arg=(AF_P2WPKH, account_num, int_ext)))
return 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,
background="\n"),
escape='1'
)
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 = item.arg
await make_descriptor_wallet_export(addr_fmt, account_num, int_ext=int_ext)
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')
account_num = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
return
# no choices to be made, just do it.
await make_bitcoin_core_wallet(account_num)
async def electrum_skeleton_step2(_1, _2, item):
# pick a semi-random file name, render and save it.
addr_fmt, account_num = item.arg
await make_json_wallet('Electrum wallet',
lambda: generate_electrum_wallet(addr_fmt, account_num),
"new-electrum.json")
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")
account_num = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
return
await make_json_wallet(label, lambda: generate_generic_export(account_num), f_pattern)
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 onto MicroSD card 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 make_json_wallet('Wasabi wallet', lambda: generate_wasabi_wallet(), 'new-wasabi.json')
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")
account_num = 0
if ch == '1':
account_num = await ux_enter_bip32_index('Account Number:') or 0
elif ch != 'y':
return
xfp = xfp2str(settings.get('xfp', 0))
fname = 'unchained-%s.json' % xfp
await make_json_wallet('Unchained',
lambda: generate_unchained_export(account_num),
fname)
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('Select file containing the backup to be verified. No password will be required.', 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, meta=None):
try:
import seed
if ephemeral:
await seed.set_ephemeral_seed_extended_key(extended_key, meta=meta)
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
force_vdisk = False
prompt, escape = import_prompt_builder("%s file" % label)
if prompt:
ch = await ux_show_story(prompt, escape=escape)
if ch == "3":
force_vdisk = None
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 ch == "2":
force_vdisk = True
elif ch == "1":
force_vdisk = False
else:
return
if force_vdisk is not None:
# only get here if NFC was not chosen
# pick a likely-looking file.
fn = await file_picker('Select file containing the %s to be imported.' % label, min_size=50,
max_size=2000, taster=contains_xprv, force_vdisk=force_vdisk)
if not fn: return
with CardSlot(force_vdisk=force_vdisk, readonly=True) 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, meta='Imported XPRV')
# not reached; will do reset.
EMPTY_RESTORE_MSG = '''\
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_temporary(*A):
fn = await file_picker('Select file containing the backup '
'to be restored as temporary seed.',
suffix=".7z", max_size=10000)
if fn:
import backups
await backups.restore_complete(fn, temporary=True)
async def restore_everything(*A):
if not pa.is_secret_blank():
await ux_show_story(EMPTY_RESTORE_MSG)
return
# restore everything, using a password, from single encrypted 7z file
fn = await file_picker('Select file containing the backup to be restored, and '
'then enter the password.', suffix='.7z', max_size=10000)
if fn:
import backups
await backups.restore_complete(fn)
async def restore_everything_cleartext(*A):
# Asssume no password on backup file; devs and crazy people only
if not pa.is_secret_blank():
await ux_show_story(EMPTY_RESTORE_MSG)
return
# restore everything, using NO password, from single text file, like would be wrapped in 7z
fn = await file_picker('Select the cleartext file containing the backup to be restored.',
suffix='.txt', max_size=10000)
if fn:
import backups
prob = await backups.restore_complete_doit(fn, [])
if prob:
await ux_show_story(prob, title='FAILED')
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 and HSM config file. Does not affect funds, or seed words but \
will reset settings used with other BIP39 passphrases. \
Does not affect MicroSD card, if any.'''):
return
from files import wipe_flash_filesystem
wipe_flash_filesystem()
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 nfc_share_file(*A):
# Mk4: 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. %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. %s" % str(e))
async def nfc_sign_msg(*A):
# Mk4: 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. %s" % str(e))
async def nfc_sign_verify(*A):
# Mk4: 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. %s" % str(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. %s" % str(e))
async def import_tapsigner_backup_file(_1, _2, item):
from glob import NFC
ephemeral = item.arg
if not ephemeral:
assert pa.is_secret_blank() # "must not have secret"
meta = "from "
force_vdisk = False
label = "TAPSIGNER encrypted backup file"
meta += label
prompt, escape = import_prompt_builder(label)
if prompt:
ch = await ux_show_story(prompt, escape=escape)
if ch == "3":
force_vdisk = None
data = await NFC.read_tapsigner_b64_backup()
if not data:
# failed to get any data - exit
# error already displayed in nfc.py
return
elif ch == "2":
force_vdisk = True
elif ch == "1":
force_vdisk = False
else:
return
if force_vdisk is not None:
fn = await file_picker('Pick ' + label, suffix="aes", min_size=100, max_size=160,
force_vdisk=force_vdisk)
if not fn: return
meta += (" (%s)" % fn)
with CardSlot(force_vdisk=force_vdisk) as card:
with open(fn, 'rb') as fp:
data = fp.read()
if await ux_show_story("Make sure to have your TAPSIGNER handy as you will need to provide "
"'Backup Password' from the back of the card in the next step. "
"Press OK to continue X to cancel.") != "y":
return
while True:
backup_key = await ux_input_text("", confirm_exit=False, hex_only=True, max_len=32)
if backup_key is None:
return
if len(backup_key) != 32:
await ux_show_story(title="FAILURE", msg="'Backup Key' length != 32")
continue
try:
extended_key, derivation = decrypt_tapsigner_backup(backup_key, data)
break
except ValueError as e:
await ux_show_story(title="FAILURE", msg=str(e))
continue
await import_extended_key_as_secret(extended_key, ephemeral, meta=meta)
async def list_files(*A):
# list files, don't do anything with them?
fn = await file_picker('Lists all files, select one and SHA256(file contents) will be shown.',
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
digest = chk.digest()
basename = fn.rsplit('/', 1)[-1]
msg_base = 'SHA256(%s)\n\n%s\n\nPress ' % (basename, B2A(digest))
msg_sign = '(4) to sign file digest and export detached signature'
msg_delete = '(6) to delete.'
msg = msg_base + msg_sign + ", press " + msg_delete
while True:
ch = await ux_show_story(msg, escape='46')
if ch == "x": break
if ch in '46':
with CardSlot() as card:
if ch == '6':
card.securely_blank_file(fn)
break
else:
from auth import write_sig_file
sig_nice = write_sig_file([(digest, fn)])
await ux_show_story("Signature file %s written." % sig_nice)
msg = msg_base + msg_delete
return
async def file_picker(msg, suffix=None, min_size=1, max_size=1000000, taster=None,
choices=None, escape=None, none_msg=None, title=None,
force_vdisk=False, batch_sign=False):
# present a menu w/ a list of files... to be read
# - optionally, enforce a max size, and provide a "tasting" function
# - if msg==None, 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
from menu import MenuSystem, MenuItem
import uos
from utils import get_filesize
if choices is None:
choices = []
try:
with CardSlot(force_vdisk=force_vdisk) 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 suffix and not fn.lower().endswith(suffix):
# wrong suffix
continue
if fn[0] == '.': 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 IOError:
#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 dno'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 msg is not None:
await needs_microsd()
return None
if msg is None:
return choices
if not choices:
msg = none_msg or 'Unable to find any suitable files for this operation. '
if not none_msg:
if suffix:
msg += 'The filename must end in "%s". ' % suffix
msg += '\n\nMaybe insert (another) SD card and try again?'
await ux_show_story(msg)
return
# tell them they need to pick; can quit here too, but that's obvious.
if len(choices) != 1:
msg += '\n\nThere are %d files to pick from.' % len(choices)
if batch_sign:
msg += '\n\nPress (9) to select all files for potential signing.'
else:
msg += '\n\nThere is only one file to pick from.'
ch = await ux_show_story(msg, escape=escape, title=title)
if batch_sign and (ch == escape == "9"):
await _batch_sign(choices=choices)
return
if escape and ch in escape: return ch
if ch == 'x': 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]
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_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
if pa.is_secondary:
await needs_primary()
return
# do it
pa.greenlight_firmware()
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):
force_vdisk = False
prompt, escape = import_prompt_builder("PSBTs", no_nfc=True)
if prompt:
ch = await ux_show_story(prompt, escape=escape)
if ch == "x": return
if ch == "2":
force_vdisk = True
if not choices:
choices = await file_picker(None, suffix='psbt', min_size=50,
force_vdisk=force_vdisk,
max_size=MAX_TXN_LEN, taster=is_psbt)
if not choices:
await ux_show_story("No PSBTs found. Need to have '.psbt' suffix.")
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 OK to sign, (1) to skip this PSBT,"
" X to quit and exit." % fn, 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(*a):
await _batch_sign()
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
import stash
from pincodes import pa
from glob import NFC
# just check if we have candidates, no UI
choices = await file_picker(None, suffix='psbt', min_size=50,
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.\n\n'''
if NFC:
msg += 'Press (3) to send PSBT using NFC.\n\n'
msg += "You will always be prompted to confirm the details before \
any signature is performed."
ch = await ux_show_story(msg, title=title, escape='3')
if ch == '3' and NFC:
await NFC.start_psbt_rx()
return
if len(choices) == 1:
# skip the menu
label,path,fn = choices[0]
input_psbt = path + '/' + fn
else:
input_psbt = await file_picker('Choose PSBT file to be signed.',
choices=choices, title=title,
batch_sign=True, escape="9")
if not input_psbt:
return
# start the process
from auth import sign_psbt_file
await sign_psbt_file(input_psbt)
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('Choose text file to be signed.', suffix='txt',
min_size=2, max_size=500, taster=is_signable, none_msg=
'No suitable files found. Must be one line of text, in a .TXT file, optionally '
'followed by a subkey derivation path on a second line and/or address format on third line.')
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(
'Choose signature file.', min_size=220, max_size=10000, taster=is_sig_file,
none_msg='No suitable files found. Must be file with ascii armor.'
)
if not fn:
return
# start the process
from auth import verify_txt_sig_file
await verify_txt_sig_file(fn)
async def pin_changer(_1, _2, item):
# Help them to change pins with appropriate warnings.
# - forcing them to drill-down to get warning about secondary is on purpose
# - the bootloader maybe lying to us about weather we are main vs. duress
# - there is a duress wallet for both main/sec pins, and you need to know main pin for that(mk3)
# - 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
mode = item.arg
# NOTE: for mk4, only "main" is applicable.
warn = {'main': ('Main PIN',
'You will be changing the main PIN used to unlock your Coldcard. '
"It's the one you just used a moment ago to get in here."),
'duress': ('Duress PIN',
'This PIN leads to a bogus wallet. Funds are recoverable '
'from main seed backup, but not as easily.'),
'secondary': ('Second PIN',
'This PIN protects the "secondary" wallet that can be used to '
'segregate funds or other banking purposes. This other wallet is '
'completely independant of the primary.'),
'brickme': ('Brickme PIN',
'Use of this special PIN code at any prompt will destroy the '
'Coldcard completely. It cannot be reused or salvaged, and '
'the secrets it held are destroyed forever.\n\nDO NOT TEST THIS!'),
}
if pa.is_secondary:
# secondary wallet user can only change their own password, and the secondary
# duress pin...
# - now excluded from menu, but keep for Mark1/2 hardware!
if mode == 'main' or mode == 'brickme':
await needs_primary()
return
if mode == 'duress' and pa.is_secret_blank():
await ux_show_story("Please set wallet seed before creating duress wallet.")
return
# are we changing the pin used to login?
is_login_pin = (mode == 'main') or (mode == 'secondary' and pa.is_secondary)
lll = LoginUX()
lll.offer_second = False
title, msg = warn[mode]
async def incorrect_pin():
await ux_show_story('You provided an incorrect value for the existing %s.' % title,
title='Wrong PIN')
return
# standard threats for all PIN's
msg += '''\n\n\
THERE IS ABSOLUTELY NO WAY TO RECOVER A FORGOTTEN PIN! Write it down.
We strongly recommend all PIN codes used be unique between each other.
'''
if not is_login_pin:
msg += '''\nUse 999999-999999 to clear existing PIN.'''
ch = await ux_show_story(msg, title=title)
if ch != 'y': return
args = {}
need_old_pin = True
if is_login_pin:
# Challenge them for old password; they probably have it, and we have it
# in memory already, because we wouldn't be here otherwise... but
# challenge them anyway as a policy choice.
need_old_pin = True
else:
# There may be no existing PIN, and we need to learn that
if mode == 'secondary':
args['is_secondary'] = True
elif mode == 'duress':
args['is_duress'] = True
need_old_pin = bool(pa.has_duress_pin())
elif mode == 'brickme':
args['is_brickme'] = True
need_old_pin = bool(pa.has_brickme_pin())
if need_old_pin and not version.has_608:
# Do an expensive check (mostly for secondary pin case?)
try:
dis.fullscreen("Check...")
pa.change(old_pin=b'', new_pin=b'', **args)
need_old_pin = False
except BootloaderError as exc:
# not an error: old pin in non-blank
need_old_pin = True
if not need_old_pin:
# It is blank
old_pin = ''
else:
# 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 is_login_pin and args['old_pin'] != pa.pin:
return await incorrect_pin()
while 1:
lll.reset()
lll.subtitle = "New " + title
pin = await lll.get_new_pin(title, allow_clear=True)
if pin is None:
return await ux_aborted()
is_clear = (pin == CLEAR_PIN)
args['new_pin'] = pin.encode() if not is_clear else b''
if args['new_pin'] == pa.pin and not is_login_pin:
await ux_show_story("Your new PIN matches the existing PIN used to get here. "
"It would be a bad idea to use it for another purpose.",
title="Try Again")
continue
from trick_pins import tp
prob = tp.check_new_main_pin(pin)
if prob:
await ux_show_story(prob, title="Try Again")
continue
break
# install it.
try:
dis.fullscreen("Clearing..." if is_clear else "Saving...")
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:
# likely: wrong old pin, on anything but main PIN
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
# - also we need pa.has_duress_pin() and has_brickme_pin() to be correct
# - this step can be super slow with 608, unfortunately
try:
dis.fullscreen("Verify...")
dis.busy_bar(True)
pa.setup(args['new_pin'] if is_login_pin else pa.pin, pa.is_secondary)
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())
if mode == 'duress':
# program the duress secret now... it's derived from real wallet contents
from stash import SensitiveValues, SecretStash, AE_SECRET_LEN
if is_clear:
# clear secret, using the new pin, which is empty string
pa.change(is_duress=True, new_secret=b'\0' * AE_SECRET_LEN, old_pin=b'')
else:
with SensitiveValues() as sv:
# derive required key
node, _ = sv.duress_root()
d_secret = SecretStash.encode(xprv=node)
sv.register(d_secret)
# write it out.
pa.change(is_duress=True, new_secret=d_secret, old_pin=args['new_pin'])
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()[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, chk=chk,
se=se, ser=serial, hw=hw)
if version.has_qr:
from glob import SCAN
msg += '\nQR Scanner:\n %s\n' % (SCAN.version or 'missing')
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 if not is_devmode else 0)
# bag number affects green light status (as does RDP level)
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_ovc(*a):
# Factory command: for dev and test units that have no bag number, and never will.
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 occured 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
from glob import settings
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
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'):
ch = await ux_show_story('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 ch != 'y':
return
return MicroSD2FA.menu()
# EOF