firmware/shared/backups.py
2019-03-27 11:00:43 -04:00

569 lines
17 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.
#
# backups.py - Save and restore backup data.
#
import compat7z, stash, tcc, ckcc, chains, gc
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
from utils import imported
from ux import ux_show_story, ux_confirm
import version, ujson
from uio import StringIO
import seed
# we make passwords with this number of words
num_pw_words = const(12)
def render_backup_contents():
# simple text format:
# key = value
# or #comments
# but value is JSON
from main import settings, pa
rv = StringIO()
def COMMENT(val=None):
if val:
rv.write('\n# %s\n' % val)
else:
rv.write('\n')
def ADD(key, val):
rv.write('%s = %s\n' % (key, ujson.dumps(val)))
rv.write('# Coldcard backup file! DO NOT CHANGE.\n')
chain = chains.current_chain()
COMMENT('Private key details: ' + chain.name)
with stash.SensitiveValues(for_backup=True) as sv:
if sv.mode == 'words':
ADD('mnemonic', tcc.bip39.from_data(sv.raw))
if sv.mode == 'master':
ADD('bip32_master_key', b2a_hex(sv.raw))
ADD('chain', chain.ctype)
ADD('xprv', chain.serialize_private(sv.node))
ADD('xpub', chain.serialize_public(sv.node))
# BTW: everything is really a duplicate of this value
ADD('raw_secret', b2a_hex(sv.secret).rstrip(b'00'))
if pa.has_duress_pin():
COMMENT('Duress Wallet (informational)')
dpk = sv.duress_root()
ADD('duress_xprv', chain.serialize_private(dpk))
ADD('duress_xpub', chain.serialize_public(dpk))
COMMENT('Firmware version (informational)')
date, vers, timestamp = version.get_mpy_version()[0:3]
ADD('fw_date', date)
ADD('fw_version', vers)
ADD('fw_timestamp', timestamp)
ADD('serial', version.serial_number())
COMMENT('User preferences')
# user preferences
for k,v in settings.current.items():
if k[0] == '_': continue # debug stuff in simulator
if k == 'xpub': continue # redundant, and wrong if bip39pw
if k == 'xfp': continue # redundant, and wrong if bip39pw
ADD('setting.' + k, v)
rv.write('\n# EOF\n')
return rv.getvalue()
async def restore_from_dict(vals):
# Restore from a dict of values. Already JSON decoded.
# Reboot on success, return string on failure
from main import pa, dis, settings
from pincodes import AE_SECRET_LEN
#print("Restoring from: %r" % vals)
# step1: the private key
# - prefer raw_secret over other values
# - TODO: fail back to other values
try:
chain = chains.get_chain(vals.get('chain', 'BTC'))
assert 'raw_secret' in vals
raw = bytearray(AE_SECRET_LEN)
x = a2b_hex(vals.pop('raw_secret'))
raw[0:len(x)] = x
# check we can decode this right (might be different firmare)
opmode, bits, node = stash.SecretStash.decode(raw)
assert node
# verify against xprv value (if we have it)
if 'xprv' in vals:
check_xprv = chain.serialize_private(node)
assert check_xprv == vals['xprv'], 'xprv mismatch'
except Exception as e:
return ('Unable to decode raw_secret and '
'restore the seed value!\n\n\n'+str(e))
dis.fullscreen("Saving...")
dis.progress_bar_show(.25)
# clear (in-memory) settings and change also nvram key
# - also captures xfp, xpub at this point
pa.change(new_secret=raw)
# force the right chain
pa.new_main_secret(raw, chain) # updates xfp/xpub
# NOTE: don't fail after this point... they can muddle thru w/ just right seed
# restore settings from backup file
for idx, k in enumerate(vals):
dis.progress_bar_show(idx / len(vals))
if not k.startswith('setting.'):
continue
if k == 'xfp' or k == 'xpub': continue
settings.set(k[8:], vals[k])
# write out
settings.save()
await ux_show_story('Everything has been successfully restored. '
'We must now reboot to install the '
'updated settings and/or seed.', title='Success!')
from machine import reset
reset()
async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
# pick a password: like bip39 but no checksum word
#
b = bytearray(32)
while 1:
ckcc.rng_bytes(b)
words = tcc.bip39.from_data(b).split(' ')[0:num_pw_words]
ch = await seed.show_words(words,
prompt="Record this (%d word) backup file password:\n", escape='6')
if ch == '6' and not write_sflash:
# Secret feature: plaintext mode
# - only safe for people living in faraday cages inside locked vaults.
if await ux_confirm("The file will **NOT** be encrypted and "
"anyone who finds the file will get all of your money for free!"):
words = []
fname_pattern = 'backup.txt'
break
continue
if ch == 'x':
return
break
if words:
# quiz them, but be nice and do a shorter test.
ch = await seed.word_quiz(words, limited=(num_pw_words//3))
if ch == 'x': return
return await write_complete_backup(words, fname_pattern, write_sflash)
async def write_complete_backup(words, fname_pattern, write_sflash):
# Just do the writing
from main import dis, pa, settings
from files import CardSlot, CardMissingError
from actions import needs_microsd
# Show progress:
dis.fullscreen('Encrypting...' if words else 'Generating...')
body = render_backup_contents().encode()
gc.collect()
if words:
# NOTE: Takes a few seconds to do the key-streching, but little actual
# time to do the encryption.
pw = ' '.join(words)
zz = compat7z.Builder(password=pw, progress_fcn=dis.progress_bar_show)
zz.add_data(body)
hdr, footer = zz.save('ckcc-backup.txt')
filesize = len(body)+2000
del body
gc.collect()
else:
# cleartext dump
zz = None
filesize = len(body)+10
if write_sflash:
# for use over USB and unit testing: commit file into SPI flash
from sffile import SFFile
with SFFile(0, max_size=filesize, message='Saving...') as fd:
await fd.erase()
if zz:
fd.write(hdr)
fd.write(zz.body)
fd.write(footer)
else:
fd.write(body)
return fd.tell(), fd.checksum.digest()
for copy in range(25):
# choose a filename
try:
with CardSlot() as card:
fname, nice = card.pick_filename(fname_pattern)
# do actual write
with open(fname, 'wb') as fd:
if zz:
fd.write(hdr)
fd.write(zz.body)
fd.write(footer)
else:
fd.write(body)
except Exception as e:
# includes CardMissingError
import sys
sys.print_exception(e)
# catch any error
ch = await ux_show_story('Failed to write! Please insert formated MicroSD card, '
'and press OK to try again.\n\nX to cancel.\n\n\n'+str(e))
if ch == 'x': break
continue
if copy == 0:
while 1:
msg = '''Backup file written:\n\n%s\n\n\
To view or restore the file, you must have the full password.\n\n\
Insert another SD card and press 2 to make another copy.''' % (nice)
ch = await ux_show_story(msg, escape='2')
if ch == 'y': return
if ch == '2': break
else:
ch = await ux_show_story('''File (#%d) written:\n\n%s\n\n\
Press OK for another copy, or press X to stop.''' % (copy+1, nice), escape='2')
if ch == 'x': break
async def verify_backup_file(fname_or_fd):
# read 7z header, and measure checksums
# - no password is wanted/required
# - really just checking CRC32, but that's enough against truncated files
from files import CardSlot, CardMissingError
from actions import needs_microsd
prob = None
fd = None
# filename already picked, open it.
try:
with CardSlot() as card:
prob = 'Unable to open backup file.'
fd = open(fname_or_fd, 'rb') if isinstance(fname_or_fd, str) else fname_or_fd
prob = 'Unable to read backup file headers. Might be truncated.'
compat7z.check_file_headers(fd)
prob = 'Unable to verify backup file contents.'
zz = compat7z.Builder()
files = zz.verify_file_crc(fd)
assert len(files) == 1
fname, fsize = files[0]
assert fname == 'ckcc-backup.txt'
assert 400 < fsize < 2000
except CardMissingError:
await needs_microsd()
return
except Exception as e:
await ux_show_story(prob + '\n\nError: ' + str(e))
return
finally:
if fd:
fd.close()
await ux_show_story("Backup file CRC checks out okay.\n\nPlease note this is only a check against accidental truncation and similar. Targeted modifications can still pass this test.")
async def restore_complete(fname_or_fd):
from ux import the_ux
async def done(words):
# remove all pw-picking from menu stack
seed.WordNestMenu.pop_all()
prob = await restore_complete_doit(fname_or_fd, words)
if prob:
await ux_show_story(prob, title='FAILED')
# give them a menu to pick from, and start picking
m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)
the_ux.push(m)
async def restore_complete_doit(fname_or_fd, words):
# Open file, read it, maybe decrypt it; return string if any error
# - some errors will be shown, None return in that case
# - no return if successful (due to reboot)
from main import dis
from files import CardSlot, CardMissingError
# build password
password = ' '.join(words)
prob = None
try:
with CardSlot() as card:
# filename already picked, taste it and maybe consider using its data.
try:
fd = open(fname_or_fd, 'rb') if isinstance(fname_or_fd, str) else fname_or_fd
except:
return 'Unable to open backup file.\n\n' + str(fname_or_fd)
try:
if not words:
contents = fd.read()
else:
try:
compat7z.check_file_headers(fd)
except Exception as e:
return 'Unable to read backup file. Has it been touched?\n\nError: ' \
+ str(e)
dis.fullscreen("Decrypting...")
try:
zz = compat7z.Builder()
fname, contents = zz.read_file(fd, password,
progress_fcn=dis.progress_bar_show)
# simple quick sanity checks
assert fname == 'ckcc-backup.txt'
assert contents[0:1] == b'#' and contents[-1:] == b'\n'
except Exception as e:
# assume everything here is "password wrong" errors
#print("pw wrong? %s" % e)
return ('Unable to decrypt backup file. Incorrect password?'
'\n\nTried:\n\n' + password)
finally:
fd.close()
except CardMissingError:
await needs_microsd()
return
vals = {}
for line in contents.decode().split('\n'):
if not line: continue
if line[0] == '#': continue
try:
k,v = line.split(' = ', 1)
#print("%s = %s" % (k, v))
vals[k] = ujson.loads(v)
except:
print("unable to decode line: %r" % line)
# but keep going!
# this leads to reboot if it works, else errors shown, etc.
return await restore_from_dict(vals)
def generate_public_contents():
# Generate public details about wallet.
#
# simple text format:
# key = value
# or #comments
# but value is JSON
from main import settings
from public_constants import AF_CLASSIC
num_rx = 5
chain = chains.current_chain()
with stash.SensitiveValues() as sv:
yield ('''\
# Coldcard Wallet Summary File
## Wallet operates on blockchain: {nb}
For BIP44, this is coin_type '{ct}', and internally we use symbol {sym} for this blockchain.
## Top-level, 'master' extended public key ('m/'):
{xpub}
Derived public keys, as may be needed for different systems:
'''.format(nb=chain.name, xpub=chain.serialize_public(sv.node),
sym=chain.ctype, ct=chain.b44_cointype))
for name, path, addr_fmt in chains.CommonDerivations:
if '{coin_type}' in path:
path = path.replace('{coin_type}', str(chain.b44_cointype))
if '{' in name:
name = name.format(core_name=chain.core_name)
yield ('''## For {name}: {path}\n\n'''.format(name=name, path=path))
submaster, kids = path.split('/{', 1)
kids = '{'+kids
node = sv.derive_path(submaster)
yield ("%s => %s\n" % (submaster, chain.serialize_public(node)))
if addr_fmt != AF_CLASSIC and (addr_fmt in chain.slip132):
yield ("# SLIP-132 style\n%s => %s\n" % (
submaster, chain.serialize_public(node, addr_fmt)))
yield ('''\n... first %d receive addresses (account=0, change=0):\n\n''' % num_rx)
for i in range(num_rx):
subpath = kids.format(account=0, change=0, idx=i)
kid = sv.derive_path(subpath, node)
yield ('%s/%s => %s\n' % (submaster, subpath, chain.address(kid, addr_fmt)))
yield ('\n\n')
async def make_summary_file(fname_pattern='public.txt'):
# record **public** values and helpful data into a text file
from main import dis, pa, settings
from files import CardSlot, CardMissingError
from actions import needs_microsd
dis.fullscreen('Generating...')
# generator function:
body = generate_public_contents()
# choose a filename
try:
with CardSlot() as card:
fname, nice = card.pick_filename(fname_pattern)
# do actual write
with open(fname, 'wb') as fd:
for part in body:
fd.write(part.encode())
except CardMissingError:
await needs_microsd()
return
except Exception as e:
await ux_show_story('Failed to write!\n\n\n'+str(e))
return
msg = '''Summary file written:\n\n%s''' % nice
await ux_show_story(msg)
def generate_electrum_wallet(addr_type):
# Generate line-by-line JSON details about wallet.
#
# Much reverse enginerring of Electrum here. It's a complex
# legacy file format.
from main import settings
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH
chain = chains.current_chain()
xfp = settings.get('xfp')
# Must get the derivation path, and the SLIP32 version bytes right!
if addr_type == AF_CLASSIC:
mode = 44
elif addr_type == AF_P2WPKH:
mode = 84
elif addr_type == AF_P2WPKH_P2SH:
mode = 49
else:
raise ValueError(addr_type)
derive = "m/{mode}'/{coin_type}'/{account}'".format(mode=mode,
account=0, coin_type=chain.b44_cointype)
with stash.SensitiveValues() as sv:
top = chain.serialize_public(sv.derive_path(derive), addr_type)
# most values are nicely defaulted, and for max forward compat, don't want to set
# anything more than I need to
rv = dict(seed_version=17, use_encryption=False, wallet_type='standard')
# the important stuff.
rv['keystore'] = dict( ckcc_xfp=xfp,
ckcc_xpub=settings.get('xpub'),
hw_type='coldcard',
label='Coldcard Import 0x%08x' % xfp,
type='hardware',
derivation=derive, xpub=top)
return rv
async def make_electrum_wallet(addr_type, fname_pattern='new-wallet.json'):
# Record **public** values and helpful data into a JSON file
from main import dis, pa, settings
from files import CardSlot, CardMissingError
from actions import needs_microsd
dis.fullscreen('Generating...')
body = generate_electrum_wallet(addr_type)
# choose a filename
try:
with CardSlot() as card:
fname, nice = card.pick_filename(fname_pattern)
# do actual write
with open(fname, 'wt') as fd:
ujson.dump(body, fd)
except CardMissingError:
await needs_microsd()
return
except Exception as e:
await ux_show_story('Failed to write!\n\n\n'+str(e))
return
msg = '''Electrum wallet file written:\n\n%s''' % nice
await ux_show_story(msg)
# EOF