Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Xor seedsave #94

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions shared/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
1 change: 1 addition & 0 deletions shared/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions shared/pincodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
69 changes: 65 additions & 4 deletions shared/xor_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
113 changes: 113 additions & 0 deletions shared/xor_seedsave.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion testing/test_seed_xor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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]
Expand Down Expand Up @@ -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)

Expand All @@ -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")

Expand Down
1 change: 1 addition & 0 deletions unix/work/MicroSD/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
*.pdf
*.dfu
*.txn
*.xor
.tmp.tmp