# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired) # import stash, ujson, ngu, pyb, os, version, aes256ctr from files import CardSlot, CardMissingError, needs_microsd from ux import ux_dramatic_pause, ux_confirm, ux_show_story, OK, X from utils import xfp2str, problem_file_line, B2A from menu import MenuItem, MenuSystem from glob import settings class PassphraseSaver: # Encrypts BIP-39 passphrase very carefully, and appends # to a file on MicroSD card. Order is preserved. # AES-256 CTR with key=SHA256(SHA256(salt + derived key off master + salt)) # where: salt=sha256(microSD serial # details) def __init__(self): self.key = None @staticmethod def filename(card): # Construct actual filename to use. # - some very minor obscurity, but we aren't relying on that. return card.get_sd_root() + '/.tmp.tmp' @classmethod def has_file(cls): # Is a card inserted with the required file(name) in place? if CardSlot.is_inserted(): try: with CardSlot() as card: # check if passphrases file exists on SD (dont read it) if card.exists(cls.filename(card)): return True except: pass return False def _calc_key(self, card, force=False): # calculate the key to be used. if not force and self.key: return salt = card.get_id_hash() with stash.SensitiveValues(bypass_tmp=True) as sv: self.key = bytearray(sv.encryption_key(salt)) def _read(self, card): # Return a list of saved passphrases, or empty list if fail. # Fail silently in all cases. Expect to see lots of noise here. assert self.key decrypt = aes256ctr.new(self.key) try: fname = self.filename(card) with open(fname, 'rb') as f: msg = f.read() txt = decrypt.cipher(msg) return ujson.loads(txt) except: return [] async def _save(self, card, data): assert self.key encrypt = aes256ctr.new(self.key) msg = encrypt.cipher(ujson.dumps(data)) # overwrites whatever already there with open(self.filename(card), 'wb') as fd: fd.write(msg) async def delete(self, idx): with CardSlot() as card: self._calc_key(card) data = self._read(card) try: del data[idx] except IndexError: pass await self._save(card, data) if not data: return True # is empty async def append(self, xfp, bip39pw): from glob import dis dis.fullscreen('Reading...') with CardSlot() as card: self._calc_key(card) data = self._read(card) to_add = dict(xfp=xfp, pw=bip39pw) if to_add not in data: dis.fullscreen('Saving...') data.append(to_add) await self._save(card, data) class PassphraseSaverMenu(MenuSystem): def update_contents(self): tmp = PassphraseSaverMenu.construct() self.replace_items(tmp) @staticmethod async def apply(menu, idx, item): # apply the password immediately and drop them at top menu from actions import goto_top_menu from ux import ux_show_story from seed import set_bip39_passphrase from pincodes import pa bypass_tmp = True pw, expect_xfp = item.arg if pa.tmp_value and settings.get("words", True): xfp = settings.get("xfp", 0) title = "[%s]" % xfp2str(xfp) msg = ( "Temporary seed is active. Press (1)" " to add passphrase to the current active" " temporary seed." ) escape = "1x" if settings.master_get("words", True): escape += "y" msg += (" Press %s to add to master seed." % OK) msg += ("Press %s to exit." % X) ch = await ux_show_story(msg, title=title, escape=escape, strict_escape=True) if ch == "x": return if ch == '1': bypass_tmp = False applied = await set_bip39_passphrase(pw, bypass_tmp=bypass_tmp, summarize_ux=False) if not applied: return xfp = settings.get('xfp', 0) # verification step if xfp == expect_xfp: # feedback that it worked await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp)) else: got = xfp2str(xfp) exp = xfp2str(expect_xfp) await ux_show_story("XFP verification failed. Restored wallet XFP [%s] " "does not match expected XFP [%s] from " "saved passphrase file." % (got, exp)) return goto_top_menu() @staticmethod async def delete_entry(menu, idx, item): from ux import the_ux from glob import dis pw_saver, i = item.arg if await ux_confirm("Delete saved passphrase?"): dis.fullscreen("Wait...") try: is_empty = await pw_saver.delete(i) the_ux.pop() if not is_empty: m = the_ux.top_of_stack() m.update_contents() else: # remove .tmp.tmp file after last passphrase # is deleted with CardSlot() as card: f_path = pw_saver.filename(card) os.remove(f_path) the_ux.pop() m = the_ux.top_of_stack() m.update_contents() except CardMissingError: await needs_microsd() except Exception as e: await ux_show_story( title="ERROR", msg='Delete failed!\n\n%s\n%s' % (e, problem_file_line(e)) ) @classmethod def construct(cls): # We have a list of xfp+pw fields. Make a menu. # Read file, decrypt and make a menu to show; OR return None # if any error hit. pw_saver = PassphraseSaver() with CardSlot() as card: pw_saver._calc_key(card) data = pw_saver._read(card) if not data: return None # Challenge: we need to hint at which is which, but don't want to # show the password on-screen. # - simple algo: # - show either first N or last N chars only # - pick which set which is all-unique, if neither, try N+1 # pws = [] for i in data: p, x = i.get('pw'), i.get('xfp') if (p,x) not in pws: pws.append( (p, x) ) for N in range(1, 8): parts = [i[0:N] + ('*'*(len(i)-N if len(i) > N else 0)) for i,_ in pws] if len(set(parts)) == len(pws): break parts = [('*'*(len(i)-N if len(i) > N else 0)) + i[-N:] for i,_ in pws] if len(set(parts)) == len(pws): break else: # give up: show it all! parts = [i for i,_ in pws] items = [] for i, (pw, label) in enumerate(zip(pws, parts)): xfp_ui = "[%s]" % xfp2str(pw[1]) submenu = MenuSystem([ MenuItem(xfp_ui), MenuItem("Restore", f=cls.apply, arg=pw), MenuItem("Delete", f=cls.delete_entry, arg=(pw_saver, i)), ]) submenu.goto_idx(1) # skip cursor to "Restore" items.append(MenuItem(label or "(empty)", menu=submenu)) return items # # 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 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 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 settings.remove_key("sd2fa") settings.save() 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 CardSlot.is_inserted(): # no card present, so nope return False s = cls() got = s.read_card() if not got: # garbage seen, missing file, etc => fail return False # 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 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 = aes256ctr.new(self.key) msg = encrypt.cipher(ujson.dumps(data)) with open(self.filename(card), 'wb') as fd: fd.write(msg) # update setting as well # TODO use general method that handles memory overflow 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 # - doesn't delete file, since might not have card anymore and useless w/o nonce 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() @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): count = item.arg if not CardSlot.is_inserted(): 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) if not ok: return 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