Example #1
0
    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
Example #2
0
    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()
Example #3
0
async def captcha_image(request):
    # make a captcha image, but always the same one per session

    ses = await get_session(request)
    if ses.new:
        return HTTPNotFound()

    from make_captcha import RansomCaptcha, MegaGifCaptcha, TOKEN_CHARS
    import random

    if 'captcha' in ses:
        # dont let them retry?
        code = ses['captcha']
    else:
        code = ''.join(random.sample(TOKEN_CHARS, 8))
        ses['captcha'] = code

    easy = BP.get('easy_captcha', settings.EASY_CAPTCHA)
    if easy:
        itype, data = RansomCaptcha(seed=code).draw(code, foreground='#444')
    else:
        itype, data = MegaGifCaptcha(seed=code).draw(code, foreground='#444')

    return web.Response(body=data, content_type='image/'+itype, 
        headers = {'Cache-Control': 'no-cache'})
Example #4
0
def update_sl(proposed):
    # We control the set_sl/allow_sl values solely for bunker purposes (sl=storage locker)

    # try to use any value already provided (but unlikely)
    xk = proposed.get('set_sl', None) or None
    if xk:
        try:
            xk = decode_sl(xk)
        except:
            logging.error(
                "Unable to decode existing storage locker; replacing",
                exc_info=1)
            xk = None

    if not xk:
        # capture settings key
        xk = BP.key

        assert len(xk) == 32
        proposed['set_sl'] = b64encode(b'Bunk' + xk).decode('ascii')

    if xk != BP.key:
        # re-use existing key, and switch over to using new/eixsting key
        BP.delete_file()
        BP.set_secret(xk)
        BP.save()
    else:
        logging.info("Re-using old secret for holding Bunker settings")

    # simple fixed value for how many times we can re-read the storage locker
    proposed['allow_sl'] = 13 if BP.get('allow_reboots', True) else 1
Example #5
0
async def homepage(request):

    # may take some details from the policy, and cook up into more useful forms

    ss = BP.get('summary', None) if STATUS.hsm.get('active') else None

    # coldcard tells us when we'll need a local code, by providing seed value
    nl = bool('next_local_code' in STATUS.hsm)

    return await add_shared_ctx(request, policy_summary=ss, needs_local=nl)
Example #6
0
async def login_post(request):

    # they must have a current session already
    # - hope this is enough against CSRF
    # - TODO: some rate limiting here, without DoS attacks
    ses = await get_session(request)
    form = await request.post()
    ok = False

    if ses.new:
        logging.warn("Bad login attempt (no cookie)")

    elif (time.time() - ses.created) > settings.MAX_LOGIN_WAIT_TIME:
        ses.invalidate()
        logging.warn("Stale login attempt (cookie too old)")

    elif ses.get('kiddie', 0):
        logging.warn("Keeping the kiddies at bay")

    else:
        captcha = form.get('captcha', '').lower()
        pw = form.get('password', None)

        if not captcha or not pw:
            # keep same captcha; they just pressed enter
            ok = False

        else:
            expect = BP.get('master_pw', settings.MASTER_PW)        # XXX scrypt(pw)
            expect_code = ses.pop('captcha', None)

            ok = (pw == expect) and (captcha == expect_code)

    if not ok:
        # fail; do nothing visible (but they will get new captcha)
        dest = URL(request.headers.get('referer', '/login'))
        return HTTPFound(dest)

    # SUCCESS
    accept_user_login(ses)

    # try to put them back where they were before
    try:
        dest = URL(request.headers.get('referer', '')).query.get('u', '/')
    except:
        dest = '/'

    logging.warn(f"Good login from user, sending to: {dest}")

    return HTTPFound(dest)
Example #7
0
async def tools_page(request):
    # various random things I've been talked into
    # - message signing useul tho
    if BP.get('policy'):
        paths = BP['policy'].get('msg_paths') or []
        paths = set(i.replace('*', '999') for i in paths)
    else:
        # priv_over_ux: we don't know, but some useful ones
        paths = ['m', "m/0/0", "m/44'/0'/0'/0/0", "m/49'/0'/0'/0/0", 
                    "m/84'/0'/0/0" ]

    paths = list(sorted(paths, key=lambda x: (len(x.split('/')), x.split('/'))))

    return await add_shared_ctx(request, msg_paths=paths)
Example #8
0
    def reset_pending_auth(self):
        # clear and setup pending auth list
        from persist import BP

        # make a list of users that might need to auth
        ul = self.hsm.get('users')
        if not ul:
            if BP.get('policy'):
                ul = set()
                try:
                    for r in BP['policy']['rules']:
                        ul.union(r.users)
                except KeyError: pass
                ul = list(sorted(ul))

        # they might have picked privacy over UX, so provide some "slots"
        # regardless of above.
        if not ul:
            ul = ['' for i in range(5)]

        # construct an obj for UX purposes, but keep the actual secrets separate
        self.pending_auth = [ObjectStruct(name=n, has_name=bool(n),
                                            has_guess='', totp=0) for n in ul]
        self._auth_guess = [None]*len(ul)
Example #9
0
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)