1061 lines
32 KiB
Python
1061 lines
32 KiB
Python
# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard <coldcardwallet.com>
|
|
# and is covered by GPLv3 license found in COPYING.
|
|
#
|
|
# actions.py
|
|
#
|
|
# Every function here is called directly by a menu item. They should all be async.
|
|
#
|
|
import ckcc, pyb
|
|
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_poll_once, ux_aborted
|
|
from utils import imported
|
|
from main import settings
|
|
from uasyncio import sleep_ms
|
|
from files import CardSlot, CardMissingError
|
|
|
|
async def start_selftest(*args):
|
|
import version
|
|
|
|
if len(args) and not version.is_factory_mode():
|
|
# called from inside menu, not directly
|
|
if not await ux_confirm('''Selftest destroys 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_microsd():
|
|
# Standard msg shown if no SD card detected when we need one.
|
|
await ux_show_story("Please insert a MicroSD card before attempting this operation.")
|
|
|
|
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/terms
|
|
|
|
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
|
|
from main import settings, pa
|
|
from version import serial_number
|
|
import callgate
|
|
|
|
tpl = '''\
|
|
Master Key Fingerprint:
|
|
|
|
{xfp:08x}
|
|
|
|
USB Serial Number:
|
|
|
|
{serial}
|
|
|
|
Extended Master Key:
|
|
|
|
{xpub}
|
|
'''
|
|
msg = tpl.format(xpub=settings.get('xpub', '(none yet)'),
|
|
xfp=settings.get('xfp', 0),
|
|
serial=serial_number())
|
|
|
|
if pa.is_secondary:
|
|
msg += '\n(Secondary wallet)\n'
|
|
|
|
bn = callgate.get_bag_number()
|
|
if bn:
|
|
msg += '\nShipping Bag:\n %s\n' % bn
|
|
|
|
await ux_show_story(msg)
|
|
|
|
async def maybe_dev_menu(*a):
|
|
from main 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.
|
|
#
|
|
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.
|
|
#
|
|
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.
|
|
from main import loop
|
|
|
|
cur = pyb.usb_mode()
|
|
if cur and 'HID' in cur:
|
|
await ux_show_story('Coldcard USB protocol is already enabled (HID mode)')
|
|
return
|
|
|
|
# might need to reset stuff?
|
|
from usb import enable_usb
|
|
|
|
# reset / re-enable
|
|
pyb.usb_mode(None)
|
|
enable_usb(loop, True)
|
|
|
|
await ux_show_story('Back to normal USB mode.')
|
|
|
|
async def microsd_upgrade(*a):
|
|
# 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
|
|
|
|
fn = await file_picker('Pick firmware image to use (.DFU)', suffix='.dfu', min_size=0x7800)
|
|
|
|
if not fn: return
|
|
|
|
failed = None
|
|
|
|
with CardSlot() as card:
|
|
with open(fn, 'rb') as fp:
|
|
from main import sf, dis
|
|
from files import dfu_parse
|
|
from ustruct import unpack_from
|
|
|
|
offset, size = dfu_parse(fp)
|
|
|
|
# get a copy of special signed heaer at the end of the flash as well
|
|
from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_HEADER_MAGIC, FWH_PY_FORMAT
|
|
hdr = bytearray(FW_HEADER_SIZE)
|
|
fp.seek(offset + FW_HEADER_OFFSET)
|
|
|
|
# basic checks only: for confused customers, not attackers.
|
|
try:
|
|
rv = fp.readinto(hdr)
|
|
assert rv == FW_HEADER_SIZE
|
|
|
|
magic_value, timestamp, version_string, pk, fw_size = \
|
|
unpack_from(FWH_PY_FORMAT, hdr)[0:5]
|
|
assert magic_value == FW_HEADER_MAGIC
|
|
assert fw_size == size
|
|
|
|
# TODO: maybe show the version string? Warn them that downgrade doesn't work?
|
|
|
|
except Exception as exc:
|
|
failed = "Sorry! That does not look like a firmware " \
|
|
"file we would want to use.\n\n\n%s" % exc
|
|
|
|
if not failed:
|
|
|
|
# copy binary into serial flash
|
|
fp.seek(offset)
|
|
|
|
buf = bytearray(256) # must be flash page size
|
|
pos = 0
|
|
dis.fullscreen("Loading...")
|
|
while pos <= size + FW_HEADER_SIZE:
|
|
dis.progress_bar_show(pos/size)
|
|
|
|
if pos == size:
|
|
# save an extra copy of the header (also means we got done)
|
|
buf = hdr
|
|
else:
|
|
here = fp.readinto(buf)
|
|
if not here: break
|
|
|
|
if pos % 4096 == 0:
|
|
# erase here
|
|
sf.sector_erase(pos)
|
|
while sf.is_busy():
|
|
await sleep_ms(10)
|
|
|
|
sf.write(pos, buf)
|
|
|
|
# full page write: 0.6 to 3ms
|
|
while sf.is_busy():
|
|
await sleep_ms(1)
|
|
|
|
pos += here
|
|
|
|
if failed:
|
|
await ux_show_story(failed, title='Corrupt')
|
|
return
|
|
|
|
# continue process...
|
|
import machine
|
|
machine.reset()
|
|
|
|
|
|
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()
|
|
pin = await lll.get_new_pin('Choose PIN', '''\
|
|
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 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.
|
|
''')
|
|
del lll
|
|
|
|
if pin is None: return
|
|
|
|
# A new pin is to be set!
|
|
from main import pa, dis, settings, loop
|
|
dis.fullscreen("Saving...")
|
|
|
|
try:
|
|
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)
|
|
|
|
# Allow USB protocol, now that we are auth'ed
|
|
from usb import enable_usb
|
|
enable_usb(loop, False)
|
|
|
|
from menu import MenuSystem
|
|
from flow import EmptyWallet
|
|
return MenuSystem(EmptyWallet)
|
|
|
|
|
|
async def block_until_login(*a):
|
|
#
|
|
# Force user to enter a valid PIN.
|
|
#
|
|
from login import LoginUX
|
|
from main import pa, loop, settings
|
|
from ux import AbortInteraction
|
|
|
|
while not pa.is_successful():
|
|
lll = LoginUX()
|
|
|
|
try:
|
|
await lll.try_login()
|
|
except AbortInteraction:
|
|
# not allowed!
|
|
pass
|
|
|
|
async def logout_now(*a):
|
|
# wipe memory and lock up
|
|
from callgate import show_logout
|
|
show_logout()
|
|
|
|
async def login_now(*a):
|
|
# wipe memory and reboot
|
|
from callgate import show_logout
|
|
show_logout(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
|
|
return seed.WordNestMenu(item.arg)
|
|
|
|
def pick_new_wallet(*a):
|
|
import seed
|
|
return seed.make_new_wallet()
|
|
|
|
|
|
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
|
|
from main import pa
|
|
|
|
if pa.has_duress_pin():
|
|
await ux_show_story('''Please empty the duress wallet, and clear the duress PIN 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
|
|
|
|
async def start_login_sequence():
|
|
# Boot up login sequence here.
|
|
#
|
|
from main import pa, settings, dis, loop
|
|
import version
|
|
|
|
if pa.is_blank():
|
|
# Blank devices, with no PIN set all, can continue w/o login
|
|
|
|
# Do green-light set immediately after firmware upgrade
|
|
if version.is_fresh_version():
|
|
pa.greenlight_firmware()
|
|
dis.show()
|
|
|
|
goto_top_menu()
|
|
return
|
|
|
|
# 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 succefully.
|
|
while not pa.is_successful():
|
|
# always get a PIN and login first
|
|
await block_until_login()
|
|
|
|
# Must read settings after login
|
|
settings.set_key()
|
|
settings.load()
|
|
|
|
# Do green-light set immediately after firmware upgrade
|
|
if not pa.is_secondary:
|
|
if version.is_fresh_version():
|
|
pa.greenlight_firmware()
|
|
dis.show()
|
|
|
|
# Populate xfp/xpub values, if missing.
|
|
# - can happen for first-time login of duress wallet
|
|
# - might indicate lost settings?
|
|
# - these values are important to USB protocol
|
|
if not (settings.get('xfp', 0) and settings.get('xpub', 0)):
|
|
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)
|
|
|
|
|
|
# Allow USB protocol, now that we are auth'ed
|
|
from usb import enable_usb
|
|
enable_usb(loop, False)
|
|
|
|
goto_top_menu()
|
|
|
|
|
|
def goto_top_menu():
|
|
# Start/restart menu system
|
|
|
|
import version
|
|
from menu import MenuSystem
|
|
from flow import VirginSystem, NormalSystem, EmptyWallet, FactoryMenu
|
|
from main import pa
|
|
|
|
if 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"
|
|
|
|
m = MenuSystem(EmptyWallet if pa.is_secret_blank() else NormalSystem)
|
|
|
|
the_ux.reset(m)
|
|
|
|
return m
|
|
|
|
SENSITIVE_NOT_SECRET = '''
|
|
|
|
The file created is sensitive--in terms of privacy--but should not \
|
|
compromise your funds directly.'''
|
|
|
|
|
|
async def dump_summary(*A):
|
|
# save addresses, and some other public details into a file
|
|
if not await ux_confirm('''\
|
|
Saves a text file to MicroSD 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.
|
|
with imported('backups') as bk:
|
|
await bk.make_summary_file()
|
|
|
|
async def electrum_skeleton(*A):
|
|
# save xpub, and some other public details into a file
|
|
if not await ux_confirm('''\
|
|
Saves a skeleton wallet file which Electrum can open, on to MicroSD. \
|
|
You can then open that file in Electrum without ever connecting the
|
|
Coldcard to a computer.''' + SENSITIVE_NOT_SECRET):
|
|
return
|
|
|
|
# TODO: pick segwit or classic derivation+such
|
|
|
|
# pick a semi-random file name, save it.
|
|
with imported('backups') as bk:
|
|
await bk.make_electrum_wallet(is_segwit=False)
|
|
|
|
async def backup_everything(*A):
|
|
# save everything, using a password, into single encrypted file, typically on SD
|
|
with imported('backups') as bk:
|
|
await bk.make_complete_backup()
|
|
|
|
async def verify_backup(*A):
|
|
# check most recent backup is "good"
|
|
# read 7z header, and measure checksums
|
|
|
|
# save everything, using a password, into single encrypted file, typically on SD
|
|
fn = await file_picker('Select file containing the backup to be verified. No password will be required.', suffix='.7z', max_size=10000)
|
|
|
|
if fn:
|
|
with imported('backups') as bk:
|
|
await bk.verify_backup_file(fn)
|
|
|
|
async def import_xprv(*A):
|
|
# read an XPRV from a text file and use it.
|
|
import tcc, chains, ure
|
|
from main import pa
|
|
from stash import SecretStash
|
|
from ubinascii import hexlify as b2a_hex
|
|
from backups import restore_from_dict
|
|
|
|
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
|
|
|
|
# pick a likely-looking file.
|
|
fn = await file_picker('Select file containing the XPRV to be imported.',
|
|
min_size=50, max_size=2000, taster=contains_xprv)
|
|
|
|
if not fn: return
|
|
|
|
node, chain = None, None
|
|
|
|
# open file and do it
|
|
pat=ure.compile(r'[xtyz]prv[A-Za-z0-9]+')
|
|
with CardSlot() as card:
|
|
with open(fn, 'rt') as fd:
|
|
for ln in fd.readlines():
|
|
if 'prv' not in ln: continue
|
|
|
|
found = pat.search(ln)
|
|
if not found: continue
|
|
|
|
found = found.group(0)
|
|
|
|
for ch in chains.AllChains:
|
|
try:
|
|
node = tcc.bip32.deserialize(found, ch.b32_version_pub, ch.b32_version_priv)
|
|
chain = ch
|
|
break
|
|
except ValueError:
|
|
pass
|
|
if node:
|
|
break
|
|
|
|
if not node:
|
|
# unable
|
|
await ux_show_story('''\
|
|
Sorry, wasn't able to find an extended private key to import. It should be at \
|
|
the start of a line, and probably starts with "xprv".''', title="Failed")
|
|
return
|
|
|
|
# encode it in our style
|
|
d = dict(chain=chain.ctype, raw_secret=b2a_hex(SecretStash.encode(xprv=node)))
|
|
node.blank()
|
|
|
|
# restore as if it was a backup (code reuse)
|
|
await restore_from_dict(d)
|
|
|
|
# not reached; will do reset.
|
|
|
|
|
|
async def restore_everything(*A):
|
|
from main import pa
|
|
|
|
if not pa.is_secret_blank():
|
|
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'.''')
|
|
return
|
|
|
|
# save everything, using a password, into single encrypted file, typically on SD
|
|
fn = await file_picker('Select file containing the backup to be restored, and '
|
|
'then enter the password.', suffix='.7z', max_size=10000)
|
|
|
|
if fn:
|
|
with imported('backups') as bk:
|
|
await bk.restore_complete(fn)
|
|
|
|
|
|
async def wipe_filesystem(*A):
|
|
if not await ux_confirm('''\
|
|
Erase internal filesystem and rebuild it. Resets contents of internal flash area \
|
|
used for code patches. Does not affect funds, settings or seed words. \
|
|
Does not affect SD card, if any.'''):
|
|
return
|
|
|
|
from files import wipe_flash_filesystem
|
|
|
|
wipe_flash_filesystem()
|
|
|
|
|
|
async def list_files(*A):
|
|
# list files, don't do anything with them?
|
|
fn = await file_picker('List files on MicroSD')
|
|
return
|
|
|
|
async def file_picker(msg, suffix=None, min_size=1, max_size=1000000, taster=None, choices=None):
|
|
# 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
|
|
from menu import MenuSystem, MenuItem
|
|
import uos
|
|
from utils import get_filesize
|
|
|
|
if choices is None:
|
|
choices = []
|
|
try:
|
|
with CardSlot() 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 = 'Unable to find any suitable files for this operation. '
|
|
|
|
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)
|
|
else:
|
|
msg += '\n\nThere is only one file to pick from.'
|
|
|
|
ch = await ux_show_story(msg)
|
|
if ch == 'x': return
|
|
|
|
picked = []
|
|
async def clicked(_1,_2,item):
|
|
picked.append('/'.join(item.arg))
|
|
the_ux.pop()
|
|
|
|
items = [MenuItem(label, f=clicked, arg=(path, fn)) for label, path, fn in choices]
|
|
|
|
if 0:
|
|
# don't like; and now showing count on previous page
|
|
if len(choices) == 1:
|
|
# if only one choice, we could make the choice for them ... except very confusing
|
|
items.append(MenuItem(' (one file)', f=None))
|
|
else:
|
|
items.append(MenuItem(' (%d files)' % len(choices), f=None))
|
|
|
|
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)
|
|
assert False # should not be reached
|
|
|
|
async def bless_flash(*a):
|
|
# make green LED turn on
|
|
from main import pa, dis
|
|
|
|
if pa.is_secondary:
|
|
await needs_primary()
|
|
return
|
|
|
|
# do it
|
|
pa.greenlight_firmware()
|
|
dis.show()
|
|
|
|
|
|
async def ready2sign(*a):
|
|
# Top menu choice of top menu! Signing!
|
|
# - check if any signable in SD card, if so do it
|
|
# - if nothing, then talk about USB connection
|
|
from public_constants import MAX_TXN_LEN
|
|
|
|
def is_psbt(filename):
|
|
if '-signed' in filename.lower():
|
|
return False
|
|
|
|
with open(filename, 'rb') as fd:
|
|
return fd.read(5) == b'psbt\xff'
|
|
|
|
choices = await file_picker(None, suffix='psbt', min_size=50,
|
|
max_size=MAX_TXN_LEN, taster=is_psbt)
|
|
|
|
if not choices:
|
|
await ux_show_story("""\
|
|
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 wallet software (Electrum) or command line tools. \
|
|
|
|
You will always be prompted to confirm the details before any signature is performed.
|
|
""")
|
|
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)
|
|
if not input_psbt:
|
|
return
|
|
|
|
# start the process
|
|
from auth import sign_psbt_file
|
|
|
|
await sign_psbt_file(input_psbt)
|
|
|
|
|
|
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
|
|
# - what may look like just policy here, is in fact enforced by the bootrom code
|
|
#
|
|
from main import pa, dis
|
|
from login import LoginUX
|
|
from pincodes import BootloaderError, EPIN_OLD_AUTH_FAIL
|
|
|
|
mode = item.arg
|
|
|
|
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...
|
|
# NOTE: now excluded from menu, but keep
|
|
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()
|
|
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\
|
|
We strongly recommend all PIN codes used be unique between each other.
|
|
|
|
There is absolutely no means to recover a lost or forgotten PIN, so please write them down!
|
|
'''
|
|
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; certainly had it, because
|
|
# we wouldn't be here otherwise.
|
|
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
|
|
elif mode == 'brickme':
|
|
args['is_brickme'] = True
|
|
|
|
old_pin = None
|
|
try:
|
|
dis.fullscreen("Check...")
|
|
pa.change(old_pin=b'', new_pin=b'', **args)
|
|
need_old_pin = False
|
|
old_pin = '' # converts to bytes below
|
|
except BootloaderError as exc:
|
|
#print("(not-error) old pin was NOT blank: %s" % exc)
|
|
need_old_pin = True
|
|
|
|
was_blank = not need_old_pin
|
|
|
|
if need_old_pin:
|
|
# We need the existing pin, so prompt for that.
|
|
#lll.subtitle = ("Existing " if len(title) < 10) else 'Old ') + title
|
|
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)
|
|
|
|
if pin is None:
|
|
return await ux_aborted()
|
|
|
|
is_clear = (pin == '999999-999999')
|
|
|
|
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
|
|
|
|
break
|
|
|
|
# install it.
|
|
dis.fullscreen("Saving...")
|
|
|
|
try:
|
|
pa.change(**args)
|
|
except Exception as exc:
|
|
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 to get pa.has_duress_pin() and has_brickme_pin() to be correct, need this
|
|
dis.fullscreen("Verify...")
|
|
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()
|
|
|
|
if mode == 'duress':
|
|
# program the duress secret now... it's derived from real wallet
|
|
from stash import SensitiveValues, SecretStash, AE_SECRET_LEN
|
|
with SensitiveValues() as sv:
|
|
if is_clear:
|
|
d_secret = b'\0' * AE_SECRET_LEN
|
|
op = b''
|
|
else:
|
|
# derive required key
|
|
node = sv.duress_root()
|
|
d_secret = SecretStash.encode(xprv=node)
|
|
sv.register(d_secret)
|
|
op = args['new_pin']
|
|
|
|
# write it out.
|
|
pa.change(is_duress=True, new_secret=d_secret, old_pin=op)
|
|
|
|
async def show_version(*a):
|
|
# show firmware, bootload versions.
|
|
from main import settings
|
|
import callgate, version
|
|
from ubinascii import hexlify as b2a_hex
|
|
|
|
built, rel, *_ = version.get_mpy_version()
|
|
bl = callgate.get_bl_version()[0]
|
|
chk = str(b2a_hex(callgate.get_bl_checksum(0))[-8:], 'ascii')
|
|
|
|
msg = '''\
|
|
Coldcard Firmware
|
|
|
|
{rel}
|
|
{built}
|
|
|
|
|
|
Bootloader:
|
|
{bl}
|
|
{chk}
|
|
|
|
Serial:
|
|
{ser}
|
|
|
|
'''
|
|
|
|
await ux_show_story(msg.format(rel=rel, built=built, bl=bl, chk=chk, ser=version.serial_number()))
|
|
|
|
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 main import dis, pa, 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 version, 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
|
|
|
|
# EOF
|