# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # usb.py - USB related things # import ckcc, pyb, callgate, sys, ux, ngu, stash, aes256ctr from uasyncio import sleep_ms, core from uhashlib import sha256 from public_constants import MAX_MSG_LEN, MAX_TXN_LEN, MAX_BLK_LEN, MAX_UPLOAD_LEN, AFC_SCRIPT from public_constants import STXN_FLAGS_MASK from ustruct import pack, unpack_from from ubinascii import hexlify as b2a_hex from ckcc import watchpoint, is_simulator import uselect as select from utils import problem_file_line, call_later_ms from version import has_fatram, is_devmode from exceptions import FramingError, CCBusyError, HSMDenied from nvstore import settings # Unofficial, unpermissioned... numbers COINKITE_VID = 0xd13e CKCC_PID = 0xcc10 # Based on the U2F descriptor: # see # however, we don't want to be detected as a U2F device, because we # don't support that protocol, and we don't want web browsers and such # trying to speak U2F/HID/USB protocol at us. This descriptor is just to # keep the HID class drivers happy, we will detect based on VID/PID. # hid_descp = bytes([ 0x06, 0xcc, 0x10, # USAGE_PAGE (CC10 = Coldcard v1.0) 0x09, 0x01, # USAGE (0x01) 0xa1, 0x01, # COLLECTION (Application) 0x09, 0x20, # USAGE (Input Report Data) 0x15, 0x00, # LOGICAL_MINIMUM (0) 0x26, 0xff, 0x00, # LOGICAL_MAXIMUM (255) 0x75, 0x08, # REPORT_SIZE (8) 0x95, 0x40, # REPORT_COUNT (64) 0x81, 0x02, # INPUT (Data,Var,Abs) 0x09, 0x21, # USAGE (Output Report Data) 0x15, 0x00, # LOGICAL_MINIMUM (0) 0x26, 0xff, 0x00, # LOGICAL_MAXIMUM (255) 0x75, 0x08, # REPORT_SIZE (8) 0x95, 0x40, # REPORT_COUNT (64) 0x91, 0x02, # OUTPUT (Data,Var,Abs) 0xc0, # END_COLLECTION ]) # Only these whitelisted USB commands are allowed once we enter HSM mode. # NOTE: 'robo' here would allow firmware changes during HSM mode! HSM_WHITELIST = frozenset({ 'logo', 'ping', 'vers', # harmless/boring 'upld', 'sha2', 'dwld', 'stxn', # up/download/sign PSBT needed 'mitm','ncry', # maybe limited by policy tho 'smsg', # limited by policy 'blkc', 'hsts', # report status values 'stok', 'smok', # completion check: sign txn or msg 'xpub', 'msck', # quick status checks 'p2sh', 'show', # limited by HSM policy 'user', # auth HSM user, other user cmds not allowed 'gslr', # read storage locker; hsm mode only, limited usage }) # singleton instance of USBHandler() handler = None def enable_usb(): # We can't change it on the fly; must be disabled before here cur = pyb.usb_mode() if cur: print("USB already enabled: %s" % cur) else: # subclass, protocol, max packet length, polling interval, report descriptor hid_info = (0x0, 0x0, 64, 5, hid_descp ) pyb.usb_mode('VCP+HID', vid=COINKITE_VID, pid=CKCC_PID, hid=hid_info) global handler if not handler: handler = USBHandler() from imptask import IMPT IMPT.start_task('USB', handler.usb_hid_recv()) def disable_usb(): # pull the plug pyb.usb_mode(None) def is_vcp_active(): # VCP = Virtual Comm Port en = ckcc.vcp_enabled(None) cur = pyb.usb_mode() return cur and ('VCP' in cur) and en class USBHandler: def __init__(self): self.dev = pyb.USB_HID() # We keep a running hash over whatever has been uploaded # - reset at offset zero, can be read back anytime self.file_checksum = sha256() self.is_fw_upgrade = False # handle simulator self.blockable = getattr(self.dev, 'pipe', self.dev) #self.msg = bytearray(MAX_MSG_LEN) from sram2 import usb_buf self.msg = usb_buf assert len(self.msg) == MAX_MSG_LEN self.encrypted_req = False # these will be objects later self.encrypt = None self.decrypt = None def get_packet(self): # read next packet (64 bytes) waiting on the wire. Unframe it and return # active part of packet, flags associated. buf = self.dev.recv(64, timeout=5000) if not buf: raise FramingError('timeout') elif len(buf) < 64: raise FramingError('short') elif len(buf) > 64: raise FramingError('long') # first byte gives us the actual size, status # all illegal combos here may become special messages someday flag = buf[0] is_last = bool(flag & 0x80) len_here = int(flag & 0x3f) is_encrypted = bool(flag & 0x40) return buf[1:1+len_here], is_last, is_encrypted async def usb_hid_recv(self): # blocks and builds up a full-length command packet in memory # - calls self.handle() once complete msg on hand msg_len = 0 while 1: yield core._io_queue.queue_read(self.blockable) try: here, is_last, is_encrypted = self.get_packet() #print('Rx[%d]' % len(here)) if here: lh = len(here) if msg_len+lh > MAX_MSG_LEN: raise FramingError('xlong') self.msg[msg_len:msg_len + lh] = here msg_len += lh else: # treat zero-length packets as a reset request # do not echo anything back on link.. used to resync connection msg_len = 0 continue if not is_last: # need more content continue if not(4 <= msg_len <= MAX_MSG_LEN): raise FramingError('badsz') if is_encrypted: if self.decrypt is None: raise FramingError('no key') self.encrypted_req = True self.decrypt_inplace(msg_len) else: self.encrypted_req = False # process request try: # this saves memory over a simple slice (confirmed) args = memoryview(self.msg)[4:msg_len] resp = await self.handle(self.msg[0:4], args) msg_len = 0 except CCBusyError: # auth UX is doing something else resp = b'busy' msg_len = 0 except HSMDenied: resp = b'err_Not allowed in HSM mode' msg_len = 0 except (ValueError, AssertionError) as exc: # some limited invalid args feedback #print("USB request caused assert: ", end='') #sys.print_exception(exc) msg = str(exc) if not msg: msg = 'Assertion ' + problem_file_line(exc) resp = b'err_' + msg.encode()[0:80] msg_len = 0 except MemoryError: # prefer to catch at higher layers, but sometimes can't resp = b'err_Out of RAM' msg_len = 0 except Exception as exc: # catch bugs and fuzzing too if is_simulator() or is_devmode: print("USB request caused this: ", end='') sys.print_exception(exc) resp = b'err_Confused ' + problem_file_line(exc) msg_len = 0 # aways send a reply if they get this far await self.send_response(resp) except FramingError as exc: reason = exc.args[0] #print("Framing: %s" % reason) self.framing_error(reason) msg_len = 0 except BaseException as exc: # recover from general issues/keep going #print("USB!") #sys.print_exception(exc) msg_len = 0 def decrypt_inplace(self, msg_len): # self.msg is encrypted. decode it in place # - seems dangerous to use memview here, but works # - some memory alloc still happens here tho self.msg[0:msg_len] = self.decrypt(memoryview(self.msg)[0:msg_len]) def encrypt_response(self, msg): # encrypt what we'll send to desktop return self.encrypt(msg) async def send_response(self, resp): # send a python object as the response # - we know how to encode a few things, or send binary # - sadly we cannot stream here because we cannot subclass streams # - cannot reuse rx buffer either! # handle simple types here if isinstance(resp, (bytes, bytearray)): # preformated assert len(resp) >= 4 elif resp is None: resp = b'okay' elif isinstance(resp, int): resp = pack('<4sI', 'int1', resp) else: #print("Unknown resp: " + repr(resp)) raise NotImplementedError() assert len(resp) >= 4 msg = bytearray(64) if self.encrypt and self.encrypted_req: resp = self.encrypt_response(resp) final_flag = 0x80 | 0x40 else: final_flag = 0x80 pos = 0 left = len(resp) while left: # sent up to 63 bytes per packet here = min(left, 63) msg[0] = here msg[1:1+here] = resp[pos:pos+here] if here == left: # no more to come assert 0 <= here < 64 msg[0] |= final_flag left -= here pos += here for retries in range(100): chk = self.dev.send(msg) if chk == 64: break # Host may not have read previous value yet, so might need # to wait for it. Data loss possible here, but also the # host may stop reading the EP forever, so not our fault. # Let other stuff run during this delay. await sleep_ms(10) def framing_error(self, why): # send error about framing, and recover self.dev.send(b'%cfram%-59s' % (4+len(why), why)) async def handle(self, cmd, args): # Dispatch incoming message, and provide reply. from glob import hsm_active try: cmd = bytes(cmd).decode() except: raise FramingError('decode') if cmd[0].isupper() and (is_simulator() or is_devmode): # special hacky commands to support testing w/ the simulator try: from usb_test_commands import do_usb_command return do_usb_command(cmd, args) except: pass if hsm_active: # only a few commands are allowed during HSM mode if cmd not in HSM_WHITELIST: raise HSMDenied if cmd == 'dfu_': # only useful in factory, undocumented. return self.call_after(callgate.enter_dfu) if cmd == 'rebo': from auth import UserAuthorizedAction, FirmwareUpgradeRequest import machine req = UserAuthorizedAction.active_request if req and isinstance(req, FirmwareUpgradeRequest): # We're waiting on firmware upgrade approval, so don't reboot # (which would not apply the upgrade anyway) and also don't # give an error, because ckcc-protocol and other clients # send the reboot as part of the old upgrade process. return return self.call_after(machine.reset) if cmd == 'logo': from utils import clean_shutdown return self.call_after(clean_shutdown) if cmd == 'ping': return b'biny' + args if cmd == 'upld': offset, total_size = unpack_from('path and M values addr_fmt, M, N, script_len = unpack_from('= total_size and not hsm_active: # probably done dis.progress_bar_show(1.0) ux.restore_menu() return offset def handle_xpub(self, subpath): # Share the xpub for the indicated subpath. Expects # a text string which is the path derivation. # TODO: might not have a privkey yet from chains import current_chain from utils import cleanup_deriv_path subpath = cleanup_deriv_path(subpath) from glob import hsm_active if hsm_active and not hsm_active.approve_xpub_share(subpath): raise HSMDenied chain = current_chain() with stash.SensitiveValues() as sv: node = sv.derive_path(subpath) xpub = chain.serialize_public(node) return b'asci' + xpub.encode() def handle_bag_number(self, bag_num): import version, callgate from glob import dis from pincodes import pa if version.is_factory_mode and bag_num: # check state first assert settings.get('tested', False) assert pa.is_blank() assert 8 <= len(bag_num) < 32 # do the change failed = callgate.set_bag_number(bag_num) assert not failed callgate.set_rdp_level(2 if not is_devmode else 0) pa.greenlight_firmware() dis.fullscreen(bytes(bag_num).decode()) self.call_after(callgate.show_logout, 1) # always report the existing/new value val = callgate.get_bag_number() or b'' return b'asci' + val # EOF