diff --git a/shared/flow.py b/shared/flow.py index 56fa00376..e9738a9ba 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -13,7 +13,7 @@ from users import make_users_menu from drv_entro import drv_entro_start from backups import clone_start, clone_write_data -from xor_seed import xor_split_start, xor_restore_start +from xor_seed import xor_save_start, xor_split_start, xor_restore_start from countdowns import countdown_pin_submenu, countdown_chooser # Optional feature: HSM @@ -230,7 +230,8 @@ def vdisk_enabled(): SeedXORMenu = [ # xxxxxxxxxxxxxxxx MenuItem("Split Existing", f=xor_split_start), - MenuItem("Restore Seed XOR", f=xor_restore_start), + MenuItem("Restore Seed XOR", menu=xor_restore_start), + MenuItem("Create XOR file", menu=xor_save_start) ] SeedFunctionsMenu = [ diff --git a/shared/manifest.py b/shared/manifest.py index c9d4a6ee3..a5664e1e2 100644 --- a/shared/manifest.py +++ b/shared/manifest.py @@ -53,6 +53,7 @@ 'version.py', 'xor_seed.py', 'ftux.py', + 'xor_seedsave.py', ], opt=0) # Optimize data-like files, since no need to debug them. diff --git a/shared/pincodes.py b/shared/pincodes.py index f832e55b2..de696c3b4 100644 --- a/shared/pincodes.py +++ b/shared/pincodes.py @@ -328,6 +328,9 @@ def has_duress_pin(self): def has_brickme_pin(self): return bool(self.state_flags & PA_HAS_BRICKME) + def has_tmp_seed(self): + return not self.tmp_value == False + def reset(self): # start over, like when you commit a new seed return self.setup(self.pin, self.is_secondary) diff --git a/shared/xor_seed.py b/shared/xor_seed.py index 5bbef423e..383b22619 100644 --- a/shared/xor_seed.py +++ b/shared/xor_seed.py @@ -5,6 +5,8 @@ # - for secret spliting on paper # - all combination of partial XOR seed phrases are working wallets # +from menu import MenuItem, MenuSystem +from xor_seedsave import XORSeedSaver import stash, ngu, chains, bip39, random from ux import ux_show_story, ux_enter_number, the_ux, ux_confirm, ux_dramatic_pause from seed import word_quiz, WordNestMenu, set_seed_value @@ -107,8 +109,10 @@ async def xor_split_start(*a): continue for ws, part in enumerate(word_parts): + print('ws, part, %s, %s'%(ws, part)) ch = await word_quiz(part, title='Word %s%%d is?' % chr(65+ws)) - if ch == 'x': break + if ch == 'x': + break else: break @@ -152,8 +156,10 @@ async def all_done(new_words): import_xor_parts.clear() # concern: we are contaminated w/ secrets return None elif ch == '1': - # do another list of words - nxt = XORWordNestMenu(num_words=24) + # do another list of words. + # fast-track to manual entry if no secret set yet. + from pincodes import pa + nxt = XORWordNestMenu(num_words=24) if pa.is_secret_blank() else XORSourceMenu() the_ux.push(nxt) elif ch == '2': # done; import on temp basis, or be the main secret @@ -169,6 +175,7 @@ async def all_done(new_words): else: pa.tmp_secret(enc) await ux_show_story("New master key in effect until next power down.") + goto_top_menu() return None @@ -177,6 +184,32 @@ def tr_label(self): pn = len(import_xor_parts) return chr(65+pn) + ' Word' + + + +class XORSourceMenu(MenuSystem): + def __init__(self): + items = [ + MenuItem('Enter Manually', menu=self.manual_entry), + MenuItem('From SDCard', f=self.from_sdcard) + ] + + super(XORSourceMenu, self).__init__(items) + + async def manual_entry(*a): + return XORWordNestMenu(num_words=24) + + async def from_sdcard(*a): + new_words = await XORSeedSaver().read_from_card() + if not new_words: + return None + + return await XORWordNestMenu.all_done(new_words) + + + + + async def show_n_parts(parts, chk_word): num_parts = len(parts) msg = 'Record these %d lists of 24-words each.\n\n' % num_parts @@ -224,6 +257,34 @@ async def xor_restore_start(*a): if len(words) == 24: import_xor_parts.append(words) - return XORWordNestMenu(num_words=24) + # fast-track to manual entry if no secret set yet. + if pa.is_secret_blank(): + return XORWordNestMenu(num_words=24) + + return XORSourceMenu() + +async def xor_save_start(*a): + from pincodes import pa + if pa.has_tmp_seed(): + ch = await ux_show_story('''\ +The current master key is a temporary one; the file will be encrypted with this key. + +Press OK to continue. X to cancel. +''') + if ch == 'x': return + + ch = await ux_show_story('''\ +Have your 24-word phrase ready. You will enter the 24 words which will then be encrypted using the master key and stored on your SDCard. + +Press OK to continue. X to cancel. +''') + if ch == 'x': return + + + async def callback(new_words): + WordNestMenu.pop_all() + return await XORSeedSaver().save_to_card(new_words) + + return WordNestMenu(num_words=24, done_cb=callback) # EOF diff --git a/shared/xor_seedsave.py b/shared/xor_seedsave.py new file mode 100644 index 000000000..dc44117ef --- /dev/null +++ b/shared/xor_seedsave.py @@ -0,0 +1,113 @@ +# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# xor_seedsave.py - Save xor seedwords into encrypted file on MicroSD (if desired) +# +import sys, stash, ujson, os, ngu +from actions import file_picker +from files import CardSlot, CardMissingError +from ux import ux_show_story + +class XORSeedSaver: + # Encrypts a 12-word seed very carefully, and appends + # to a file on MicroSD card. + # AES-256 CTR with key=SHA256(SHA256(salt + derived key off master + salt)) + # where: salt=sha256(microSD serial # details) + + def _calc_key(self, card): + # calculate the key to be used. + if getattr(self, 'key', None): return + + try: + salt = card.get_id_hash() + + with stash.SensitiveValues(bypass_pw=True) as sv: + self.key = bytearray(sv.encryption_key(salt)) + + except: + self.key = None + + def _read(self, filename): + # Return 24 words from encrypted file, or empty list if fail. + # Fail silently in all cases. + decrypt = ngu.aes.CTR(self.key) + + try: + msg = open(filename, 'rb').read() + txt = decrypt.cipher(msg) + val = ujson.loads(txt) + + # If contents are not what we expect, return nothing + if not type(val) is list: + return [] + if not len(val) == 24: + return [] + + return val + except: + return [] + + def _write(self, filename, words): + # Encrypt and save words to file. + # Allow exceptions to throw as validation should + # have been performed before calling. + encrypt = ngu.aes.CTR(self.key) + json = ujson.dumps(words) + contents = encrypt.cipher(json) + open(filename, 'wb').write(contents) + + async def read_from_card(self): + import pyb + if not pyb.SDCard().present(): + await ux_show_story("Insert an SDCard and try again.") + return None + + choices = await file_picker(None, suffix='xor') + filename = await file_picker('Choose your XOR file.', choices=choices) + + if not filename: + return None + + # 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) + if not self.key: + await ux_show_story("Failed to read file!\n\nNo action has been taken.") + return None + + data = self._read(filename) + if not data: + await ux_show_story("Failed to read file!\n\nNo action has been taken.") + return None + + return data + except CardMissingError: + # not an error: they just aren't using feature + await ux_show_story("Failed to read file!\n\nNo action has been taken.") + return None + + async def save_to_card(self, words): + msg = ('Confirm these %d secret words:\n') % len(words) + msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words)) + ch = await ux_show_story(msg, sensitive=True) + if ch == 'x': return + + import pyb + while not pyb.SDCard().present(): + ch = await ux_show_story('Please insert an SDCard!\n\nPress OK to continue, X to cancel') + if ch == 'x': return + + from glob import dis + # Show progress: + dis.fullscreen('Encrypting...') + + with CardSlot() as card: + filename, nice = card.pick_filename('seedwords.xor') + self._calc_key(card) + self._write(filename, words) + await ux_show_story('XOR file written:\n\n%s' % nice) + + return None + +# EOF diff --git a/testing/test_seed_xor.py b/testing/test_seed_xor.py index 209d8f81e..2c670cfab 100644 --- a/testing/test_seed_xor.py +++ b/testing/test_seed_xor.py @@ -22,7 +22,7 @@ ( [ones32]*7, ones32), ( [ones32]*4, zero32), ]) -def test_import_xor(incl_self, parts, expect, goto_home, pick_menu_item, cap_story, need_keypress, cap_menu, word_menu_entry, get_secrets, reset_seed_words, set_seed_words): +def test_import_xor_manual(incl_self, parts, expect, goto_home, pick_menu_item, cap_story, need_keypress, cap_menu, word_menu_entry, get_secrets, reset_seed_words, set_seed_words): # values from docs/seed-xor.md, and some easy cases @@ -50,6 +50,8 @@ def test_import_xor(incl_self, parts, expect, goto_home, pick_menu_item, cap_sto else: need_keypress('y') + pick_menu_item('Enter Manually') + #time.sleep(0.01) for n, part in enumerate(parts): @@ -65,6 +67,7 @@ def test_import_xor(incl_self, parts, expect, goto_home, pick_menu_item, cap_sto if n != len(parts)-1: need_keypress('1') + pick_menu_item('Enter Manually') else: # correct anticipated checksum word chk_word = expect.split()[-1] @@ -165,6 +168,7 @@ def test_import_zero_set(goto_home, pick_menu_item, cap_story, need_keypress, ca assert 'you have a seed already' in body assert '(1) to include this Coldcard' in body need_keypress('y') + pick_menu_item('Enter Manually') #time.sleep(0.01) @@ -181,6 +185,8 @@ def test_import_zero_set(goto_home, pick_menu_item, cap_story, need_keypress, ca return need_keypress('1') + pick_menu_item('Enter Manually') + raise pytest.fail("reached") diff --git a/unix/work/MicroSD/.gitignore b/unix/work/MicroSD/.gitignore index 7ac52166e..a5df5c78e 100644 --- a/unix/work/MicroSD/.gitignore +++ b/unix/work/MicroSD/.gitignore @@ -6,4 +6,5 @@ *.pdf *.dfu *.txn +*.xor .tmp.tmp