370 lines
13 KiB
Python
Executable File
370 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
|
#
|
|
# Repackage and sign the firmware image.
|
|
#
|
|
import os, sys, struct, pdb, click
|
|
from collections import namedtuple
|
|
from binascii import b2a_hex
|
|
from hashlib import sha256
|
|
from ecdsa import SigningKey, VerifyingKey
|
|
from ecdsa.curves import SECP256k1
|
|
from sigheader import *
|
|
|
|
# more details about header
|
|
header = namedtuple('header', FWH_PY_VALUES)
|
|
packed_len = struct.calcsize(FWH_PY_FORMAT)
|
|
assert packed_len == FW_HEADER_SIZE, \
|
|
"FWH_PY_FORMAT is wrong: gives %d length, not %d" % (packed_len, FW_HEADER_SIZE)
|
|
|
|
def pad_to(orig, size, fill=b'\xff'):
|
|
assert len(orig) <= size, "too big; no room for padding: %d > %d" % (len(orig), size)
|
|
|
|
return orig.ljust(size, fill)
|
|
|
|
def align_to(n, alignment):
|
|
# align to # of bytes (a power of two)
|
|
return (n + alignment - 1) & ~(alignment-1)
|
|
|
|
def timestamp(backdate=0):
|
|
# render 16-byte BCD timecode in something like ISO 8601
|
|
import datetime
|
|
n = datetime.datetime.utcnow()
|
|
if backdate:
|
|
n -= datetime.timedelta(days=backdate)
|
|
|
|
f = n.strftime('%y%0m%0e%0H%0M%0S0000').encode('ascii')
|
|
assert len(f) == 16
|
|
|
|
# hex string to binary, but BCD..isn't that just a2b_hex?
|
|
rv = bytes([ ((f[i]& 0xf) << 4) | (f[i+1] & 0xf) for i in range(0, 16, 2)])
|
|
|
|
assert len(rv) == 8, len(rv)
|
|
|
|
return rv
|
|
|
|
# Options we want for all commands
|
|
@click.group()
|
|
def main():
|
|
pass
|
|
|
|
@main.command()
|
|
@click.option('-n', type=int, help='Which key # to make', default=1)
|
|
@click.option('--path-pattern', '-p',
|
|
default='keys/%02d.pem', type=str, help='Where to put results')
|
|
def make_keys(n, path_pattern):
|
|
"Constuct new keys (only can be used once)"
|
|
# run once only
|
|
|
|
from ecdsa.util import randrange_from_seed__trytryagain
|
|
|
|
def make_key_from_seed(seed, curve=SECP256k1):
|
|
secexp = randrange_from_seed__trytryagain(seed, curve.order)
|
|
return SigningKey.from_secret_exponent(secexp, curve)
|
|
|
|
assert 1 <= n < 16
|
|
|
|
if 0:
|
|
# deterministic
|
|
seed = 'ehllo'
|
|
sk1 = make_key_from_seed("%02d:%s" % (n, seed))
|
|
else:
|
|
# actually used:
|
|
sk1 = make_key_from_seed(os.urandom(128))
|
|
|
|
fn = path_pattern % n
|
|
assert not os.path.exists(fn), "Already exists: " + fn
|
|
|
|
pubkey = sk1.get_verifying_key()
|
|
|
|
open(fn, 'wb').write(sk1.to_pem())
|
|
fn = fn.replace('.pem', '.pubkey')
|
|
open(fn+'.pem', 'wb').write(pubkey.to_pem())
|
|
#open(fn+'.bin', 'wb').write(pubkey.to_string())
|
|
open(fn+'.c', 'wt').write(', '.join('0x%02x'%i for i in pubkey.to_string()))
|
|
|
|
|
|
@main.command('version')
|
|
@click.argument('fname')
|
|
def show_version(fname):
|
|
# just dump the version number in a form that makes for good filenames
|
|
data = open(fname, 'rb').read()
|
|
|
|
if data[0:5] == b'DfuSe':
|
|
# Got DFU file, pulling out raw binary.
|
|
(_, _, data),*_ = dfu_parse(open(fname, 'rb'))
|
|
|
|
hdr = data[FW_HEADER_OFFSET:FW_HEADER_OFFSET+FW_HEADER_SIZE ]
|
|
|
|
hdr = header(**dict(zip(FWH_PY_VALUES.split(), struct.unpack(FWH_PY_FORMAT, hdr))))
|
|
|
|
ver = str(hdr.version_string.split(b'\0', 1)[0], 'ascii')
|
|
ts = str(b2a_hex(hdr.timestamp), 'ascii')
|
|
built = '20' + '-'.join(ts[i:i+2] for i in range(0, 6, 2))
|
|
built += 'T' + ''.join(ts[i:i+2] for i in range(6, 10, 2))
|
|
|
|
print('{built}-v{ver}'.format(built=built, ver=ver))
|
|
|
|
def dfu_parse(fd):
|
|
# do just a little parsing of DFU headers, to find start/length of main binary
|
|
# - not trying to support anything but what ../stm32/Makefile will generate
|
|
# - see external/micropython/tools/pydfu.py for details
|
|
# - works sequentially only
|
|
import struct
|
|
from collections import namedtuple
|
|
|
|
fd.seek(0)
|
|
|
|
def consume(xfd, tname, fmt, names):
|
|
# Parses the struct defined by `fmt` from `data`, stores the parsed fields
|
|
# into a named tuple using `names`. Returns the named tuple.
|
|
size = struct.calcsize(fmt)
|
|
here = xfd.read(size)
|
|
ty = namedtuple(tname, names.split())
|
|
values = struct.unpack(fmt, here)
|
|
return ty(*values)
|
|
|
|
dfu_prefix = consume(fd, 'DFU', '<5sBIB', 'signature version size targets')
|
|
|
|
#print('dfu: ' + repr(dfu_prefix))
|
|
|
|
assert dfu_prefix.signature == b'DfuSe', "Not a DFU file (bad magic)"
|
|
|
|
for idx in range(dfu_prefix.targets):
|
|
|
|
prefix = consume(fd, 'Target', '<6sBI255s2I',
|
|
'signature altsetting named name size elements')
|
|
|
|
#print("target%d: %r" % (idx, prefix))
|
|
|
|
for ei in range(prefix.elements):
|
|
# Decode target prefix
|
|
# < little endian
|
|
# I uint32_t element address
|
|
# I uint32_t element size
|
|
elem = consume(fd, 'Element', '<2I', 'addr size')
|
|
|
|
#print("target%d: %r" % (ei, elem))
|
|
|
|
yield fd.tell(), elem.size, fd.read(elem.size)
|
|
|
|
|
|
@main.command('split')
|
|
@click.argument('dfu', metavar='202....-coldcard.dfu')
|
|
@click.argument('firmware', metavar='FIRMWARE.bin')
|
|
@click.argument('bootrom', metavar='BOOTROM.bin')
|
|
def split_dfu(dfu, firmware, bootrom):
|
|
"Pull out sections from DFU file for verification purposes"
|
|
|
|
with open(dfu, 'rb') as fd:
|
|
for n, (off, ln, data) in enumerate(dfu_parse(fd)):
|
|
if n == 0:
|
|
target = firmware
|
|
name = 'Firmware'
|
|
elif n == 1:
|
|
target = bootrom
|
|
name = 'Bootrom'
|
|
else:
|
|
raise ValueError(n)
|
|
|
|
# keep this printout so others can check our copy is faithful
|
|
print(f'start {off} for {ln} bytes: {name} => {target}')
|
|
|
|
open(target, 'wb').write(data)
|
|
|
|
@main.command('check')
|
|
@click.argument('fname', default='firmware-signed.bin')
|
|
def readback(fname):
|
|
"Verify pubkey and signature used in binary file"
|
|
data = open(fname, 'rb').read()
|
|
|
|
if data[0:5] == b'DfuSe':
|
|
click.secho("Got DFU file, pulling out raw binary.", fg='red')
|
|
(_, _, data),*_ = dfu_parse(open(fname, 'rb'))
|
|
|
|
hdr = data[FW_HEADER_OFFSET:FW_HEADER_OFFSET+FW_HEADER_SIZE ]
|
|
|
|
vals = {}
|
|
for fld, v in zip(FWH_PY_VALUES.split(), struct.unpack(FWH_PY_FORMAT, hdr)):
|
|
vals[fld] = v
|
|
|
|
if fld == 'version_string':
|
|
v = str(v.split(b'\0', 1)[0], 'ascii')
|
|
elif fld in ('magic_value'):
|
|
v = hex(v)
|
|
elif fld in ('signature', 'future'):
|
|
v = str(b2a_hex(v), 'ascii')
|
|
v = v[0:16] + ' ... ' + v[-16:]
|
|
elif fld == 'install_flags':
|
|
nv = '0x%x =>' % v
|
|
if v & FWHIF_HIGH_WATER:
|
|
nv += ' HIGH_WATER'
|
|
v = nv
|
|
elif fld == 'hw_compat':
|
|
nv = '0x%x => ' % v
|
|
d = []
|
|
if v & MK_1_OK: d.append('Mk1')
|
|
if v & MK_2_OK: d.append('Mk2')
|
|
if v & MK_3_OK: d.append('Mk3')
|
|
if v & MK_4_OK: d.append('Mk4')
|
|
if v & MK_5_OK: d.append('Mk5')
|
|
if v & MK_Q1_OK: d.append('Q1')
|
|
if v & ~(MK_1_OK | MK_2_OK | MK_3_OK | MK_4_OK | MK_5_OK | MK_Q1_OK):
|
|
d.append('?other?')
|
|
v = nv + '+'.join(d)
|
|
elif fld == 'timestamp':
|
|
v = str(b2a_hex(v), 'ascii')
|
|
nv = '20' + '-'.join(v[i:i+2] for i in range(0, 6, 2)) + ' '
|
|
nv += ':'.join(v[i:i+2] for i in range(6, 6+6, 2))
|
|
v = nv + ' UTC'
|
|
|
|
print("%16s: %s" % (fld, v))
|
|
|
|
# non-useful value, fixed.
|
|
#print('runtime hdr at: 0x%08x' % (0x08008000 + FW_HEADER_OFFSET))
|
|
|
|
a = sha256(data[0:FW_HEADER_OFFSET+FW_HEADER_SIZE-64])
|
|
a.update(data[FW_HEADER_OFFSET+FW_HEADER_SIZE:])
|
|
chk = sha256(a.digest()).digest()
|
|
|
|
print("sha256^2: %s" % b2a_hex(chk).decode('ascii'))
|
|
|
|
# from pubkey
|
|
vk = VerifyingKey.from_pem(open("keys/%02d.pubkey.pem" % vals['pubkey_num']).read())
|
|
|
|
try:
|
|
ok = vk.verify_digest(vals['signature'], chk)
|
|
except:
|
|
ok = False
|
|
|
|
print('%16s: %s' % ("ECDSA Signature", ('CORRECT' if ok else 'Wrong, wrong, wrong!!!')))
|
|
|
|
|
|
@main.command('sign')
|
|
@click.argument('version', required=True)
|
|
@click.option('--pubkey-num', '-k', type=int, help='Which key # to use for signing', default=0)
|
|
@click.option('--high_water', '-h', is_flag=True, help='Mark version as new highwater mark (no downgrades below this version)')
|
|
@click.option('--verbose', '-v', default=False, is_flag=True, help='Show numbers related to signature')
|
|
@click.option('--hw-compat', '-m', type=str, metavar='mk', help="Set HW compat field (hw_label value)")
|
|
@click.option('--backdate', type=int, metavar='DAYS',
|
|
help='Make downgrade attack test version', default=0)
|
|
@click.option('--build_dir', '-b', default='l-port/build-COLDCARD')
|
|
@click.option('--resign_file', '-r', type=click.File('rb'),
|
|
help='Replace existing signature', default=None)
|
|
@click.option('--outfn', '-o', type=click.Path(),
|
|
help='Output filename', default='firmware-signed.bin')
|
|
@click.option('--keydir', type=str, metavar='DIRPATH', help="Where to find priv keys for signing", default='keys')
|
|
def doit(keydir, outfn=None, build_dir=None, high_water=False,
|
|
current=False, hw_compat=None,
|
|
version='0.1a', pubkey_num=0, backdate=0, verbose=False, resign_file=None):
|
|
"Add signature into binary file before it becomes a DFU file."
|
|
|
|
assert len(version) < 8, "Version string limited to 8 bytes, got: %r" % version
|
|
|
|
# load key
|
|
try:
|
|
sk = SigningKey.from_pem(open(f"{keydir}/{pubkey_num:02d}.pem").read())
|
|
except FileNotFoundError:
|
|
click.secho(f"You don't have that key ({pubkey_num}), so using key zero instead!", fg='red')
|
|
pubkey_num = 0
|
|
sk = SigningKey.from_pem(open(f"{keydir}/{pubkey_num:02d}.pem").read())
|
|
|
|
if resign_file:
|
|
whole = resign_file.read()
|
|
vectors = whole[0:FW_HEADER_OFFSET]
|
|
body = whole[FW_HEADER_OFFSET+FW_HEADER_SIZE:]
|
|
#click.echo('%s: %d + (128) + %d size' % (resign_file.name, len(vectors), len(body)))
|
|
else:
|
|
vectors = open(build_dir + '/firmware0.bin', 'rb').read()
|
|
body = open(build_dir + '/firmware1.bin', 'rb').read()
|
|
|
|
if hw_compat in { 'mk4', '4', 'mk5', '5', 'mk' }:
|
|
# Mk4 and 5 can run the same firmware, once Mk5 support was added
|
|
hw_compat = MK_4_OK | MK_5_OK
|
|
elif hw_compat == 'q1':
|
|
hw_compat = MK_Q1_OK
|
|
elif hw_compat in { 'mk3', '3'}:
|
|
hw_compat = MK_2_OK | MK_3_OK
|
|
else:
|
|
assert not "known"
|
|
|
|
assert len(vectors) <= FW_HEADER_OFFSET, "isr vectors area is too big!"
|
|
assert len(body) >= FW_MIN_LENGTH, "main firmware is too small: %d" % len(body)
|
|
|
|
body_len = align_to(len(body), 512)
|
|
|
|
if hw_compat & (MK_1_OK | MK_2_OK | MK_3_OK):
|
|
# bugfix: size must be non-page aligned, so extra bytes are erased past end
|
|
if (body_len % 4096) == 0:
|
|
body_len += 512
|
|
assert body_len % 512 == 0, body_len
|
|
else:
|
|
# bugfix: PSRAM-based products (Mk4, Q1) need to erase 4k blocks, so
|
|
# trouble happens if final binary isn't aligned to that size.
|
|
body_len = align_to(body_len, 4096)
|
|
assert body_len % 4096 == 0, body_len
|
|
|
|
# pad out
|
|
vectors = pad_to(vectors, FW_HEADER_OFFSET)
|
|
body = pad_to(body, body_len)
|
|
version = pad_to(version.encode('ascii'), 8, b'\0')
|
|
|
|
hdr = header( magic_value=FW_HEADER_MAGIC,
|
|
version_string=version,
|
|
firmware_length=FW_HEADER_OFFSET+FW_HEADER_SIZE+body_len,
|
|
install_flags=(FWHIF_HIGH_WATER if high_water else 0x0),
|
|
hw_compat=hw_compat,
|
|
best_ts=bytes(8),
|
|
future=b'\0'*(4*FWH_NUM_FUTURE),
|
|
signature=b'\xff'*64,
|
|
pubkey_num=pubkey_num,
|
|
timestamp=timestamp(backdate) )
|
|
|
|
|
|
if hw_compat & MK_3_OK:
|
|
# actual file length limited by size of SPI flash area reserved to txn data/uploads
|
|
assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH, hdr.firmware_length
|
|
USB_MAX_LEN = (786432-128)
|
|
else:
|
|
# new value for Mk4 and later: limited only by final binary size, not SPI flash
|
|
assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH_MK4, hdr.firmware_length
|
|
USB_MAX_LEN = 1472 * 1024
|
|
|
|
assert hdr.firmware_length <= USB_MAX_LEN, \
|
|
"too big for our USB upgrades: %d = %d bytes too big" % (
|
|
hdr.firmware_length, hdr.firmware_length-USB_MAX_LEN)
|
|
|
|
print("Remaining flash space: %d bytes" % (USB_MAX_LEN - hdr.firmware_length))
|
|
|
|
binhdr = struct.pack(FWH_PY_FORMAT, *hdr)
|
|
assert len(binhdr) == FW_HEADER_SIZE
|
|
assert len(vectors + binhdr[:-64]) == 0x3fc0
|
|
|
|
hashable = vectors + binhdr[:-64] + body
|
|
fw_hash = sha256(sha256(hashable).digest()).digest()
|
|
|
|
assert len(fw_hash) == 32
|
|
|
|
if verbose:
|
|
print("Hdr: %s" % repr(hdr))
|
|
print('Hash: %s' % b2a_hex(fw_hash).decode('ascii'))
|
|
|
|
from ecdsa.util import sigencode_string
|
|
sig = sk.sign_digest(fw_hash, sigencode=sigencode_string)
|
|
|
|
assert len(sig) == 64
|
|
final = binhdr[:-64] + sig
|
|
assert len(final) == FW_HEADER_SIZE
|
|
|
|
if verbose:
|
|
print('Signature: %s' % b2a_hex(sig).decode('ascii'))
|
|
|
|
open(outfn, 'wb').write(vectors + final + body)
|
|
|
|
if verbose:
|
|
print("Wrote: %s" % outfn)
|
|
print("Signed by pubkey=%d install_flags=0x%x" % (hdr.pubkey_num, hdr.install_flags))
|
|
|
|
# EOF
|