async def hsm_status(self, h=None): # refresh HSM status b4 = STATUS.hsm.get('active', False) try: b4_nlc = STATUS.hsm.get('next_local_code') h = h or (await self.send_recv(CCProtocolPacker.hsm_status())) STATUS.hsm = h = json_loads(h) STATUS.notify_watchers() except MissingColdcard: h = {} if h.get('next_local_code') and STATUS.psbt_hash: if b4_nlc != h.next_local_code: STATUS.local_code = calc_local_pincode( a2b_hex(STATUS.psbt_hash), h.next_local_code) else: # won't be required STATUS.local_code = None # has it just transitioned into HSM mode? if STATUS.connected and STATUS.hsm.active and not b4: await self.activated_hsm() return STATUS.hsm
async def connect(self, raise_on_error=True): from stem.connection import connect if self.controller: return self.controller def doit(): self.controller = connect(control_port=('127.0.0.1', settings.TORD_PORT)) if self.controller: logging.info("Tord version: " + str(self.controller.get_version())) else: logging.error("Unable to connect to local 'tord' server") if raise_on_error: raise RuntimeError("No local 'tord' server") return self.controller loop = asyncio.get_running_loop() rv = await loop.run_in_executor(executor, doit) STATUS.tord_good = bool(rv) STATUS.notify_watchers()
async def hsm_status(self, h=None): # refresh HSM status b4 = STATUS.hsm.get('active', False) try: b4_nlc = STATUS.hsm.get('next_local_code') h = h or (await self.send_recv(CCProtocolPacker.hsm_status())) STATUS.hsm = h = json_loads(h) STATUS.notify_watchers() except MissingColdcard: h = {} if h.get('next_local_code') and STATUS.psbt_hash: if b4_nlc != h.next_local_code: STATUS.local_code = calc_local_pincode( a2b_hex(STATUS.psbt_hash), h.next_local_code) else: # won't be required STATUS.local_code = None if ('summary' in h) and h.summary and not BP.get( 'priv_over_ux') and not BP.get('summary'): logging.info("Captured CC's summary of the policy") BP['summary'] = h.summary BP.save() # has it just transitioned into HSM mode? if STATUS.connected and STATUS.hsm.active and not b4: await self.activated_hsm() return STATUS.hsm
async def run(self): # connect to, and maintain a connection to a single Coldcard logging.info("Connecting to Coldcard.") while 1: try: if not self.serial and os.path.exists(settings.SIMULATOR_SOCK): # if simulator is running, just use it. sn = settings.SIMULATOR_SOCK else: sn = self.serial d = ColdcardDevice(sn=sn) logging.info(f"Found Coldcard {d.serial}.") await asyncio.get_running_loop().run_in_executor( executor, d.check_mitm) async with self.lock: self.dev = d except: logging.error("Cannot connect to Coldcard (will retry)", exc_info=0) await asyncio.sleep(settings.RECONNECT_DELAY) continue # stay connected, and check we are working periodically logging.info(f"Connected to Coldcard {self.dev.serial}.") STATUS.connected = True # read static info about coldcard STATUS.xfp = xfp2str(self.dev.master_fingerprint) STATUS.serial_number = self.dev.serial STATUS.is_testnet = (self.dev.master_xpub[0] == 't') STATUS.hsm = {} STATUS.reset_pending_auth() STATUS.notify_watchers() await self.hsm_status() while 1: await asyncio.sleep(settings.PING_RATE) try: # use long timeout here, even tho simple command, because the CC may # we working on something else right now (thinking). h = await self.send_recv(CCProtocolPacker.hsm_status(), timeout=20000) logging.info("ping ok") await self.hsm_status(h) except MissingColdcard: self._conn_broken() break except: logging.error("Ping failed", exc_info=1)
async def activated_hsm(self): # just connected to a Coldcard w/ HSM active already # - ready storage locker, decrypt and use those settings logging.info("Coldcard now in HSM mode. Fetching storage locker.") try: sl = await self.get_storage_locker() except CCProtoError as exc: if 'consumed' in str(exc): import os, sys msg = "Coldcard refused access to storage locker. Reboot it and enter HSM again" logging.error(msg) print(msg, file=sys.stderr) sys.exit(1) else: raise try: import policy xk = policy.decode_sl(sl) except: logging.error("Unable to parse contents of storage locker: %r" % sl) return if BP.open(xk): # unable to read our settings specific to this CC? Go to defaults # or continue? logging.error( "Unable to read bunker settings for this Coldcard; forging on") else: STATUS.sl_loaded = True if BP.get( 'tor_enabled', False) and not (STATUS.force_local_mode or STATUS.setup_mode): # get onto Tor as a HS from torsion import TOR STATUS.tor_enabled = True logging.info(f"Starting hidden service: %s" % BP['onion_addr']) asyncio.create_task(TOR.start_tunnel()) h = STATUS.hsm if ('summary' in h) and h.summary and not BP.get( 'priv_over_ux') and not BP.get('summary'): logging.info("Captured CC's summary of the policy") BP['summary'] = h.summary BP.save() STATUS.reset_pending_auth() STATUS.notify_watchers()
def _conn_broken(self, setup_time=False): # our connection is lost, so clear/reset system state if self.dev: self.dev.close() self.dev = None STATUS.connected = False STATUS.xfp = None STATUS.serial_number = None STATUS.is_testnet = False STATUS.hsm = {} STATUS.reset_pending_auth() if not setup_time: BP.reset() STATUS.notify_watchers()
async def stop_tunnel(self): # hang up if running if not self.service: return def doit(): if self.service: logging.info( f"Disconnecting previous service at: {self.service.service_id}.onion" ) self.controller.remove_ephemeral_hidden_service( self.service.service_id) self.service = None STATUS.onion_addr = None STATUS.notify_watchers() loop = asyncio.get_running_loop() return await loop.run_in_executor(executor, doit)
async def start_tunnel(self): from persist import BP, settings c = await self.connect() def doit(): if self.service: logging.info( f"Disconnecting previous service at: {self.service.service_id}.onion" ) self.controller.remove_ephemeral_hidden_service( self.service.service_id) self.service = None # give Tor the key from earlier run k = BP['onion_pk'] s = self.controller.create_ephemeral_hidden_service( {80: settings.PORT_NUMBER}, detached=False, discard_key=True, await_publication=True, key_type='ED25519-V3', key_content=k) addr = s.service_id + '.onion' assert addr == BP[ 'onion_addr'], f"Mismatch, got: {addr} not {BP.onion_addr} expected" self.service = s return addr loop = asyncio.get_running_loop() addr = await loop.run_in_executor(executor, doit) STATUS.onion_addr = addr STATUS.notify_watchers()
async def ws_api_handler(ses, send_json, req, orig_request): # handle_api # # Handle incoming requests over websocket; send back results. # req = already json parsed request coming in # send_json() = means to send the response back # action = req.action args = getattr(req, 'args', None) #logging.warn("API action=%s (%r)" % (action, args)) # MAJOR info leak XXX logging.debug(f"API action={action}") if action == '_connected': logging.info("Websocket connected: %r" % args) # can send special state update at this point, depending on the page elif action == 'start_hsm_btn': await Connection().hsm_start() await send_json(show_flash_msg=APPROVE_CTA) elif action == 'delete_user': name, = args assert 1 <= len(name) <= MAX_USERNAME_LEN, "bad username length" await Connection().delete_user(name.encode('utf8')) # assume it worked, so UX updates right away try: STATUS.hsm.users.remove(name) except ValueError: pass STATUS.notify_watchers() elif action == 'create_user': name, authmode, new_pw = args assert 1 <= len(name) <= MAX_USERNAME_LEN, "bad username length" assert ',' not in name, "no commas in names" if authmode == 'totp': mode = USER_AUTH_TOTP | USER_AUTH_SHOW_QR new_pw = '' elif authmode == 'rand_pw': mode = USER_AUTH_HMAC | USER_AUTH_SHOW_QR new_pw = '' elif authmode == 'give_pw': mode = USER_AUTH_HMAC else: raise ValueError(authmode) await Connection().create_user(name.encode('utf8'), mode, new_pw) # assume it worked, so UX updates right away try: STATUS.hsm.users = list(set(STATUS.hsm.users + [name])) except ValueError: pass STATUS.notify_watchers() elif action == 'submit_policy': # get some JSON w/ everything the user entered. p, save_copy = args proposed = policy.web_cleanup(json_loads(p)) policy.update_sl(proposed) await Connection().hsm_start(proposed) STATUS.notify_watchers() await send_json(show_flash_msg=APPROVE_CTA) if save_copy: d = policy.desensitize(proposed) await send_json(local_download=dict(data=json_dumps(d, indent=2), filename=f'hsm-policy-{STATUS.xfp}.json.txt')) elif action == 'download_policy': proposed = policy.web_cleanup(json_loads(args[0])) await send_json(local_download=dict(data=json_dumps(proposed, indent=2), filename=f'hsm-policy-{STATUS.xfp}.json.txt')) elif action == 'import_policy': # they are uploading a JSON capture, but need values we can load in Vue proposed = args[0] cooked = policy.web_cookup(proposed) await send_json(vue_app_cb=dict(update_policy=cooked), show_flash_msg="Policy file imported.") elif action == 'pick_onion_addr': from torsion import TOR addr, pk = await TOR.pick_onion_addr() await send_json(vue_app_cb=dict(new_onion_addr=[addr, pk])) elif action == 'pick_master_pw': pw = b64encode(os.urandom(12)).decode('ascii') pw = pw.replace('/', 'S').replace('+', 'p') assert '=' not in pw await send_json(vue_app_cb=dict(new_master_pw=pw)) elif action == 'new_bunker_config': from torsion import TOR # save and apply config values nv = json_loads(args[0]) assert 4 <= len(nv.master_pw) < 200, "Master password must be at least 4 chars long" # copy in simple stuff for fn in [ 'tor_enabled', 'master_pw', 'easy_captcha', 'allow_reboots']: if fn in nv: BP[fn] = nv[fn] # update onion stuff only if PK is known (ie. they changed it) if nv.get('onion_pk', False) or False: for fn in [ 'onion_addr', 'onion_pk']: if fn in nv: BP[fn] = nv[fn] BP.save() await send_json(show_flash_msg="Bunker settings encrypted and saved to disk.") STATUS.tor_enabled = BP['tor_enabled'] STATUS.notify_watchers() if not BP['tor_enabled']: await TOR.stop_tunnel() elif BP.get('onion_pk') and not (STATUS.force_local_mode or STATUS.setup_mode) \ and TOR.get_current_addr() != BP.get('onion_addr'): # disconnect/reconnect await TOR.start_tunnel() elif action == 'sign_message': # sign a short text message # - lots more checking could be done here, but CC does it anyway msg_text, path, addr_fmt = args addr_fmt = AF_P2WPKH if addr_fmt != 'classic' else AF_CLASSIC try: sig, addr = await Connection().sign_text_msg(msg_text, path, addr_fmt) except: # get the spinner to stop: error msg will be "refused by policy" typically await send_json(vue_app_cb=dict(msg_signing_result='(failed)')) raise sig = b64encode(sig).decode('ascii').replace('\n', '') await send_json(vue_app_cb=dict(msg_signing_result=f'{sig}\n{addr}')) elif action == 'upload_psbt': # receiving a PSBT for signing size, digest, contents = args psbt = b64decode(contents) assert len(psbt) == size, "truncated/padded in transit" assert sha256(psbt).hexdigest() == digest, "corrupted in transit" STATUS.import_psbt(psbt) STATUS.notify_watchers() elif action == 'clear_psbt': STATUS.clear_psbt() STATUS.notify_watchers() elif action == 'preview_psbt': STATUS.psbt_preview = 'Wait...' STATUS.notify_watchers() try: txt = await Connection().sign_psbt(STATUS._pending_psbt, flags=STXN_VISUALIZE) txt = txt.decode('ascii') # force some line splits, especially for bech32, 32-byte values (p2wsh) probs = re.findall(r'([a-zA-Z0-9]{36,})', txt) for p in probs: txt = txt.replace(p, p[0:30] + '\u22ef\n\u22ef' + p[30:]) STATUS.psbt_preview = txt except: # like if CC doesn't like the keys, whatever .. STATUS.psbt_preview = None raise finally: STATUS.notify_watchers() elif action == 'auth_set_name': idx, name = args assert 0 <= len(name) <= MAX_USERNAME_LEN assert 0 <= idx < len(STATUS.pending_auth) STATUS.pending_auth[idx].name = name STATUS.notify_watchers() elif action == 'auth_offer_guess': idx, ts, guess = args assert 0 <= idx < len(STATUS.pending_auth) STATUS.pending_auth[idx].totp = ts STATUS.pending_auth[idx].has_guess = 'x'*len(guess) STATUS._auth_guess[idx] = guess STATUS.notify_watchers() elif action == 'submit_psbt': # they want to sign it now expect_hash, send_immediately, finalize, wants_dl = args assert expect_hash == STATUS.psbt_hash, "hash mismatch" if send_immediately: assert finalize, "must finalize b4 send" logging.info("Starting to sign...") STATUS.busy_signing = True STATUS.notify_watchers() try: dev = Connection() # do auth steps first (no feedback given) for pa, guess in zip(STATUS.pending_auth, STATUS._auth_guess): if pa.name and guess: await dev.user_auth(pa.name, guess, int(pa.totp), a2b_hex(STATUS.psbt_hash)) STATUS.reset_pending_auth() try: result = await dev.sign_psbt(STATUS._pending_psbt, finalize=finalize) logging.info("Done signing") msg = "Transaction signed." if send_immediately: msg += '<br><br>' + broadcast_txn(result) await send_json(show_modal=True, html=Markup(msg), selector='.js-api-success') result = (b2a_hex(result) if finalize else b64encode(result)).decode('ascii') fname = 'transaction.txt' if finalize else ('signed-%s.psbt'%STATUS.psbt_hash[-6:]) if wants_dl: await send_json(local_download=dict(data=result, filename=fname, is_b64=(not finalize))) await dev.hsm_status() except CCUserRefused: logging.error("Coldcard refused to sign txn") await dev.hsm_status() r = STATUS.hsm.get('last_refusal', None) if not r: raise HTMLErroMsg('Refused by local user.') else: raise HTMLErrorMsg(f"Rejected by Coldcard.<br><br>{r}") finally: STATUS.busy_signing = False STATUS.notify_watchers() elif action == 'shutdown_bunker': await send_json(show_flash_msg="Bunker is shutdown.") await asyncio.sleep(0.25) logging.warn("User-initiated shutdown") asyncio.get_running_loop().stop() sys.exit(0) elif action == 'leave_setup_mode': # During setup process, they want to go Tor mode; which I consider leaving # setup mode ... in particular, logins are required. # - button label is "Start Tor" tho ... so user doesn't see it that way assert STATUS.setup_mode, 'not in setup mode?' assert BP['tor_enabled'], 'Tor not enabled (need to save?)' addr = BP['onion_addr'] assert addr and '.onion' in addr, "Bad address?" STATUS.setup_mode = False await send_json(show_flash_msg="Tor hidden service has been enabled. " "It may take a few minutes for the website to become available") STATUS.notify_watchers() from torsion import TOR logging.info(f"Starting hidden service: %s" % addr) asyncio.create_task(TOR.start_tunnel()) elif action == 'logout_everyone': # useful for running battles... # - changes crypto key for cookies, so they are all invalid immediately. from aiohttp_session.nacl_storage import NaClCookieStorage import nacl logging.warning("Logout of everyone!") # reset all session cookies storage = orig_request.get('aiohttp_session_storage') assert isinstance(storage, NaClCookieStorage) storage._secretbox = nacl.secret.SecretBox(os.urandom(32)) # kick everyone off (bonus step) for w in web_sockets: try: await send_json(redirect='/logout', _ws=w) await w.close() except: pass else: raise NotImplementedError(action)