diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 05ed0466..06a744e1 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,5 +1,8 @@ ## 5.1.0 - 2023-02-08 +- New Feature: "MicroSD card as Second Factor". Specially marked MicroSD card must be + already inserted when (true) PIN is entered, or else seed is wiped. Add, remove and check + cards in menu: Settings -> Login Settings -> MicroSD 2FA - New Feature: Single signature wallet generic descriptor export `Advanced -> Export Wallet -> Descriptor`. Both new format with internal/external in one descriptor `<0;1>` and standard with two descriptors are supported. diff --git a/shared/actions.py b/shared/actions.py index e03e9bb7..f5038a62 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -4,7 +4,7 @@ # # Every function here is called directly by a menu item. They should all be async. # -import ckcc, pyb, version, uasyncio +import ckcc, pyb, version, uasyncio, sys from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted, ux_enter_bip32_index, ux_input_text from utils import imported, pretty_short_delay, problem_file_line, import_prompt_builder from uasyncio import sleep_ms @@ -855,7 +855,7 @@ async def start_login_sequence(): except BaseException as exc: # Robustness: any logic errors/bugs in above will brick the Coldcard - # even for legit owner, since they can't login. Try to recover, when it's + # even for legit owner, since they can't login. To try to recover, when it's # safe to do so. Remember the bootrom checks PIN on every access to # the secret, so "letting" them past this point is harmless if they don't know # the true pin. @@ -863,7 +863,6 @@ async def start_login_sequence(): raise print("Bug recovery!") - import sys sys.print_exception(exc) # Successful login... @@ -879,6 +878,14 @@ async def start_login_sequence(): except: pass + # Maybe insist on the "right" microSD being already installed? + try: + from pwsave import MicroSD2FA + MicroSD2FA.enforce_policy() + except BaseException as exc: + # robustness: keep going! + sys.print_exception(exc) + # implement idle timeout now that we are logged-in from imptask import IMPT IMPT.start_task('idle', idle_logout()) @@ -2088,5 +2095,16 @@ async def change_which_chain(name): # no secrets yet, not an error pass +async def microsd_2fa(*a): + # Feature: enforce special MicroSD being inserted at login time (a 2FA) + from pwsave import MicroSD2FA + + if not settings.get('sd2fa'): + ch = await ux_show_story('''When enabled, this feature requires a specially prepared MicroSD card to be inserted during login process. After correct PIN is provided, if card slot is empty or unknown card present, the seed is wiped.''') + + if ch != 'y': + return + + return MicroSD2FA.menu() # EOF diff --git a/shared/flow.py b/shared/flow.py index 6b90a0b2..a7cfb7c7 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -114,6 +114,7 @@ LoginPrefsMenu = [ MenuItem('Scramble Keypad', f=pick_scramble), MenuItem('Kill Key', f=pick_killkey, predicate=lambda: version.has_se2), MenuItem('Login Countdown', chooser=countdown_chooser), + MenuItem('MicroSD 2FA', menu=microsd_2fa, predicate=lambda: version.has_se2 and has_secrets()), MenuItem('Test Login Now', f=login_now, arg=1), ] diff --git a/shared/nvstore.py b/shared/nvstore.py index f7855ebf..f04dcce8 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -55,6 +55,7 @@ from glob import PSRAM # emu = (bool) if set, enables the USB Keyboard emulation (BIP-85 password entry) # wa = (bool) if set, enables menu wraparound # hsmcmd = (bool) if set, enables all user management and hsm-only USB commands +# sd2fa = (list of strings): track which SD card is needed for login # Stored w/ key=00 for access before login # _skip_pin = hard code a PIN value (dangerous, only for debug) # nick = optional nickname for this coldcard (personalization) diff --git a/shared/pwsave.py b/shared/pwsave.py index 259dabec..c8d604d7 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -2,8 +2,9 @@ # # pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired) # -import sys, stash, ujson, os, ngu +import sys, stash, ujson, os, ngu, pyb from files import CardSlot, CardMissingError, needs_microsd +from ux import ux_dramatic_pause, ux_confirm, ux_show_story class PassphraseSaver: # Encrypts BIP-39 passphrase very carefully, and appends @@ -16,9 +17,10 @@ class PassphraseSaver: # - some very minor obscurity, but we aren't relying on that. return card.get_sd_root() + '/.tmp.tmp' - def _calc_key(self, card): + def _calc_key(self, card, force=False): # calculate the key to be used. - if getattr(self, 'key', None): return + if not force and getattr(self, 'key', None): + return try: salt = card.get_id_hash() @@ -35,16 +37,20 @@ class PassphraseSaver: decrypt = ngu.aes.CTR(self.key) try: - msg = open(self.filename(card), 'rb').read() + fname = self.filename(card) + msg = open(fname, 'rb').read() txt = decrypt.cipher(msg) + return ujson.loads(txt) + except OSError: + #print('missing? ' + fname) + return [] except: return [] async def append(self, xfp, bip39pw): # encrypt and save; always appends. - from ux import ux_dramatic_pause from glob import dis while 1: @@ -79,7 +85,6 @@ class PassphraseSaver: from actions import goto_top_menu from ux import ux_show_story from seed import set_bip39_passphrase - import pyb # Very quick check for card not present case. if not pyb.SDCard().present(): @@ -143,5 +148,211 @@ class PassphraseSaver: return MenuSystem((MenuItem(label or '(empty)', f=doit, arg=pw) for pw, label in zip(pws, parts))) + +# +# Support for using MicroSD as second factor to the login PIN. +# + +class MicroSD2FA(PassphraseSaver): + def filename(self, card): + # Construct actual filename to use. + # - want to support same card authorizing multiple CC, so cant be fixed filename + # - dont want to search tho, so should be deterministic + # - serial number of CC is nearly public but hmac anyway + # - if this file was written from a trick pin situation, it would have + # correct filename but contents would not decrypt since AES key is based off seed + import version + from utils import B2A + + k = ngu.hash.sha256s(version.serial_number()) + h = ngu.hmac.hmac_sha256(k, b'silly?') + + return card.get_sd_root() + '/.%s.2fa' % B2A(h[0:8]) + + @classmethod + def get_nonces(cls): + # this is the only setting: list of nonce values we have saved to various cards + from glob import settings + return settings.get('sd2fa') or [] + + def read_card(self): + # Read the data, if any, and if decrypted correctly + + # Read file, decrypt and make a menu to show; OR return None + # if any error hit. + try: + with CardSlot() as card: + self._calc_key(card, force=True) + if not self.key: return None + + data = self._read(card) + if not data: return None + except CardMissingError: + # late fail + return None + + return data + + @classmethod + def enforce_policy(cls): + # If feature enabled, and if so check authorized card is inserted right now. + nonces = cls.get_nonces() + if not nonces: + # feature not in use, no problem + return + + try: + ok = cls.authorized_card_present(nonces) + assert ok == True + except: + # die. wrong + import callgate + callgate.fast_wipe(silent=False) + + # proceed w/o any notice + return + + @classmethod + def authorized_card_present(cls, nonces): + # Check if good card present + + if not pyb.SDCard().present(): + # no card present, so nope + return False + + s = cls() + got = s.read_card() + if not got: + # garbage seen, missing file, etc => fail + #print('2fa file decrypt fail') + return False + #print(repr(got)) + #print(repr(nonces)) + + # check it is in the list of authorized cards + return (got['nonce'] in nonces) + + async def enroll(self): + # Write little file, update our settings to allow this card to auth. + from utils import B2A + from glob import dis, settings + + nonce = B2A(ngu.random.bytes(8)) + + v = list(self.get_nonces()) + + # encrypt and save; always appends. + + dis.fullscreen('Saving...') + + try: + with CardSlot() as card: + self._calc_key(card, force=True) + + data = dict(nonce=nonce) + + encrypt = ngu.aes.CTR(self.key) + msg = encrypt.cipher(ujson.dumps(data)) + + with open(self.filename(card), 'wb') as fd: + fd.write(msg) + + # update setting as well + v.append(nonce) + settings.set('sd2fa', v) + settings.save() + + await ux_dramatic_pause("Saved.", 1) + + return + + except CardMissingError: + return await needs_microsd() + + async def remove(self, nonce): + # remove indicated nonce from records + # - delete file if present and found, but ok if missing + from glob import dis, settings + + v = self.get_nonces() + assert nonce in v, 'missing card nonce' + v2 = [i for i in v if i != nonce] + if not v2: + settings.remove_key('sd2fa') + else: + settings.set('sd2fa', v2) + settings.save() + + try: + with CardSlot() as card: + fn = self.filename(card) + os.remove(fn) + except: + pass + + @classmethod + def menu(cls): + # menu contents needed for current state + from menu import MenuItem + + existing = cls.get_nonces() + menu = [] + + menu.append(MenuItem("Add Card", f=cls.menu_enroll, arg=len(existing))) + + if existing: + menu.append(MenuItem("Check Card", f=cls.menu_check_card)) + + for n, card_nonce in enumerate(existing): + menu.append(MenuItem("Remove Card #%d" % (n+1), f=cls.menu_edit, arg=card_nonce)) + + return menu + + @classmethod + async def menu_check_card(cls, *a): + + ok = cls.authorized_card_present(cls.get_nonces()) + if not ok: + await ux_show_story("This card would NOT be accepted during login.", title="FAIL") + else: + await ux_show_story("This card is enrolled and would be accepted during login.", title="PASS") + + @classmethod + async def menu_enroll(cls, menu, label, item): + from files import _is_ejected + + count = item.arg + + if _is_ejected(): + return await needs_microsd() + + # careful: if they re-enrolled same card twice, confusion will result + if count: + ok = cls.authorized_card_present(cls.get_nonces()) + if ok: + await ux_show_story("Need a different MicroSD card. " + "This card would already be accepted.") + return + + ctx = 'this card or one of the others' if count >= 1 else 'it' + + ok = await ux_confirm("Add this card to authorized set? Going forward %s must be present during login process or the seed will be wiped!" % ctx) + + + await cls().enroll() + + menu.replace_items(cls.menu()) + + @classmethod + async def menu_edit(cls, menu, label, item): + # only allowing delete for now... could show details or something + ok = await ux_confirm("Remove this card from authorized set?") + if not ok: + return + + # delete magic file if we can, but more importantly our nonce + await cls().remove(item.arg) + + menu.replace_items(cls.menu()) # EOF diff --git a/shared/users.py b/shared/users.py index 59ba8f3f..7bea8bfe 100644 --- a/shared/users.py +++ b/shared/users.py @@ -16,6 +16,7 @@ from glob import settings # accepting strings and strings, returning bytes when decoding, str when encoding (ie. correct) b32encode = ngu.codecs.b32_encode b32decode = ngu.codecs.b32_decode + hmac_sha256 = ngu.hmac.hmac_sha256 # to keep menus and such to a reasonable size