Skip to content

Commit 3ad1e7a

Browse files
committed
Support recovery of seed from sources, extra seed entropy
1 parent e4e19ff commit 3ad1e7a

File tree

2 files changed

+204
-12
lines changed

2 files changed

+204
-12
lines changed

slip39/App/main.py

Lines changed: 203 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import argparse
2+
import codecs
3+
import hashlib
24
import logging
35
import math
46
import os
57

68
import PySimpleGUI as sg
79

810
from ..types import Account
9-
from ..api import create, group_parser
11+
from ..api import create, group_parser, random_secret
12+
from ..recovery import recover, recover_bip39
1013
from ..util import log_level, log_cfg
1114
from ..layout import write_pdfs
1215
from ..defaults import GROUPS, GROUP_THRESHOLD_RATIO, CRYPTO_PATHS
@@ -24,13 +27,15 @@
2427
)
2528
B_kwds = dict(
2629
font = font,
30+
enable_events = True,
2731
)
2832

2933

3034
def groups_layout( names, group_threshold, groups, passphrase=None ):
3135
"""Return a layout for the specified number of SLIP-39 groups.
3236
3337
"""
38+
3439
group_body = [
3540
sg.Frame(
3641
'#', [[ sg.Column( [
@@ -57,29 +62,96 @@ def groups_layout( names, group_threshold, groups, passphrase=None ):
5762
], key='-GROUP-SIZES-' ) ]]
5863
),
5964
]
60-
prefix = (30,1)
61-
layout = [
65+
prefix = (32, 1)
66+
inputs = (32, 1)
67+
bip39_sample = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
68+
layout = [
69+
[
70+
sg.Frame( 'Seed Data Source', [
71+
[
72+
sg.Radio( "128-bit Random", "SD", key='-SD-128-RND-', default=True, **B_kwds ),
73+
sg.Radio( "256-bit Random", "SD", key='-SD-256-RND-', default=False, **B_kwds ),
74+
sg.Radio( "512-bit Random", "SD", key='-SD-512-RND-', default=False, **B_kwds ),
75+
],
76+
[
77+
sg.Radio( "128-bit Fixed ", "SD", key='-SD-128-FIX-', default=False, **B_kwds ),
78+
sg.Radio( "256-bit Fixed ", "SD", key='-SD-256-FIX-', default=False, **B_kwds ),
79+
sg.Radio( "512-bit Fixed ", "SD", key='-SD-512-FIX-', default=False, **B_kwds ),
80+
],
81+
[
82+
sg.Radio( "BIP-39", "SD", key='-SD-BIP-', default=False, **B_kwds ),
83+
sg.Radio( "SLIP-39", "SD", key='-SD-SLIP-', default=False, **B_kwds ),
84+
],
85+
[
86+
sg.Frame( 'From', [
87+
[
88+
sg.Text( "Mnemonic(s): ", key='-SD-DATA-T-', size=prefix, **T_kwds ),
89+
sg.Multiline( bip39_sample, key='-SD-DATA-', size=(128,1), **I_kwds )
90+
],
91+
], key='-SD-DATA-F-', visible=False ),
92+
],
93+
[
94+
sg.Frame( 'Pass', [
95+
[
96+
sg.Text( "Passphrase (decrypt): ", size=prefix, **T_kwds ),
97+
sg.Input( "", key='-SD-PASS-', **I_kwds )
98+
],
99+
], key='-SD-PASS-F-', visible=False ),
100+
],
101+
[
102+
sg.Text( "Seed Data: ", size=prefix, **T_kwds ),
103+
sg.Text( f"", key='-SD-SEED-', size=(128,1), **T_kwds ),
104+
],
105+
] )
106+
],
107+
] + [
108+
[
109+
sg.Frame( '⊻ Seed Extra Entropy (eg. Die rolls, ...)', [
110+
[
111+
sg.Radio( "Hex", "SE", key='-SE-HEX-', default=True, **B_kwds ),
112+
sg.Radio( "SHA-512 Stretch", "SE", key='-SE-SHA-', default=False, **B_kwds ),
113+
],
114+
[
115+
sg.Text( "Entropy: ", key='-SE-DATA-T-', size=prefix, **T_kwds ),
116+
sg.Input( "", key='-SE-DATA-', size=(128,1), **I_kwds ),
117+
],
118+
[
119+
sg.Text( "Seed Entropy: ", size=prefix, **T_kwds ),
120+
sg.Text( f"", key='-SE-SEED-', size=(128,1), **T_kwds ),
121+
],
122+
] ),
123+
]
124+
] + [
125+
[
126+
sg.Frame( 'Seed Master Secret', [
127+
[
128+
sg.Text( "Seed: ", size=prefix, **T_kwds ),
129+
sg.Text( f"", key='-SEED-', size=(128,1), **T_kwds ),
130+
],
131+
] ),
132+
],
133+
] + [
62134
[
63135
sg.Text( "Save PDF to (ie. USB drive): ", size=prefix, **T_kwds ),
64-
sg.Input( sg.user_settings_get_entry( "-target folder-", ""), k='-TARGET-', **I_kwds ),
65-
sg.FolderBrowse( **B_kwds )
136+
sg.Input( sg.user_settings_get_entry( "-target folder-", ""), size=inputs, key='-TARGET-', **I_kwds ),
137+
sg.FolderBrowse( **B_kwds ),
66138
],
67139
] + [
68140
[
69141
sg.Text( "Seed File Name(s): ", size=prefix, **T_kwds ),
70-
sg.Input( f"{', '.join( names )}", key='-NAMES-', **I_kwds ),
142+
sg.Input( f"{', '.join( names )}", size=inputs, key='-NAMES-', **I_kwds ),
71143
sg.Text( "(optional; comma-separated)", **T_kwds ),
72144
]
73145
] + [
74146
[
75-
sg.Text( "Requires recovery of at least: ", size=prefix, **T_kwds ),
76-
sg.Input( f"{group_threshold}", key='-THRESHOLD-', **I_kwds ),
147+
sg.Text( "Requires recovery of at least: ", size=prefix, **T_kwds ),
148+
sg.Input( f"{group_threshold}", key='-THRESHOLD-', size=inputs, **I_kwds ),
77149
sg.Text( f"(of {len(groups)} SLIP-39 Recovery Groups)", key='-RECOVERY-', **T_kwds ),
78150
],
79151
] + [
80152
[
81-
sg.Text( "Passphrase to encrypt Seed: ", size=prefix, **T_kwds ),
82-
sg.Input( f"{passphrase or ''}", key='-PASSPHRASE-', **I_kwds ),
153+
sg.Text( "Passphrase (encrypt): ", size=prefix, **T_kwds ),
154+
sg.Input( f"{passphrase or ''}", key='-PASSPHRASE-', size=inputs, **I_kwds ),
83155
sg.Text( "(optional; must be remembered separately!!)", **T_kwds ),
84156
],
85157
] + [
@@ -146,6 +218,9 @@ def app(
146218
status = None
147219
event = False
148220
events_termination = (sg.WIN_CLOSED, 'Exit',)
221+
master_secret = None # default to produce randomly
222+
seed_data = None
223+
seed_entr = None
149224
while event not in events_termination:
150225
# Create window (for initial window.read()), or update status
151226
if window:
@@ -161,6 +236,7 @@ def app(
161236
continue
162237

163238
if event == '+':
239+
# Add a SLIP39 Groups row
164240
g = len(groups)
165241
name = f"Group {g+1}"
166242
needs = (2,3)
@@ -170,6 +246,111 @@ def app(
170246
window.extend_layout( window['-GROUP-NEEDS-'], [[ sg.Input( f"{needs[0]}", key=f"-G-NEED-{g}", **I_kwds ) ]] ) # noqa: 241
171247
window.extend_layout( window['-GROUP-SIZES-'], [[ sg.Input( f"{needs[1]}", key=f"-G-SIZE-{g}", **I_kwds ) ]] ) # noqa: 241
172248

249+
# Respond to changes in the desired Seed Data source, and recover/generate and update the -SD-SEED-
250+
for seed_data_source in [
251+
'-SD-128-RND-',
252+
'-SD-256-RND-',
253+
'-SD-512-RND-',
254+
'-SD-128-FIX-',
255+
'-SD-256-FIX-',
256+
'-SD-512-FIX-',
257+
'-SD-BIP-',
258+
'-SD-SLIP-',
259+
]:
260+
if values.get( seed_data_source ) and seed_data != seed_data_source:
261+
seed_data = seed_data_source
262+
# Changed Seed Data source!
263+
if 'FIX' in seed_data:
264+
window['-SD-DATA-T-'].update( f"Hex data: " )
265+
window['-SD-DATA-F-'].update( visible=True )
266+
window['-SD-PASS-F-'].update( visible=False )
267+
elif 'RND' in seed_data:
268+
window['-SD-DATA-F-'].update( visible=False )
269+
window['-SD-PASS-F-'].update( visible=False )
270+
elif 'BIP' in seed_data:
271+
window['-SD-DATA-T-'].update( f"BIP-39 Mnemonic: " )
272+
window['-SD-DATA-F-'].update( visible=True )
273+
window['-SD-PASS-F-'].update( visible=True )
274+
elif 'SLIP' in seed_data:
275+
window['-SD-DATA-T-'].update( f"SLIP-39 Mnemonics: " )
276+
window['-SD-DATA-F-'].update( visible=True )
277+
window['-SD-PASS-F-'].update( visible=True )
278+
if 'FIX' in seed_data:
279+
bits = int( seed_data.split( '-' )[2] )
280+
try:
281+
# 0-fill and truncate any supplied hex data to the desired bit length
282+
data = f"{values['-SD-DATA-']:<0{bits//4}.{bits//4}}"
283+
master_secret = codecs.decode( data, 'hex_codec' )
284+
except Exception as exc:
285+
status = f"Invalid Hex for {bits}-bit fixed seed: {exc}"
286+
continue
287+
elif 'BIP' in seed_data:
288+
try:
289+
master_secret = recover_bip39(
290+
mnemonic = values['-SD-DATA-'].strip(),
291+
passphrase = values['-SD-PASS-'].strip().encode( 'UTF-8' )
292+
)
293+
except Exception as exc:
294+
status = f"Invalid BIP-39 recovery mnemonic: {exc}"
295+
continue
296+
elif 'SLIP' in seed_data:
297+
try:
298+
master_secret = recover(
299+
mnemonics = values['-SD-DATA-'].strip().split( '\n' ),
300+
passphrase = values['-SD-PASS-'].strip().encode( 'UTF-8' )
301+
)
302+
except Exception as exc:
303+
status = f"Invalid SLIP-39 recovery mnemonics: {exc}"
304+
continue
305+
else: # Random. Regenerated each time through.
306+
bits = int( seed_data.split( '-' )[2] )
307+
master_secret = random_secret( bits // 8 )
308+
309+
# Compute the Seed Data as hex. Will be 128-, 256- or 512-bit hex data.
310+
window['-SD-SEED-'].update( codecs.encode( master_secret, 'hex_codec' ).decode( 'ascii' ))
311+
312+
# Respond to changes in the Seed Entropy, and recover/generate the -SE-SEED-. It is
313+
# expected to be exactly the same size as the -SD-SEED- data.
314+
for seed_entr_source in [
315+
'-SE-HEX-',
316+
'-SE-SHA-',
317+
]:
318+
if values.get( seed_entr_source ) and seed_entr != seed_entr_source:
319+
seed_entr = seed_entr_source
320+
# Changed Seed Entropy source!
321+
if 'HEX' in seed_entr:
322+
window['-SE-DATA-T-'].update( f"Entropy (hex): " )
323+
else:
324+
window['-SE-DATA-T-'].update( f"Entropy (die rolls, etc.): " )
325+
326+
bits = len( window['-SD-SEED-'].get() ) * 4
327+
if 'HEX' in seed_entr:
328+
try:
329+
# 0-fill and truncate any supplied hex data to the desired bit length
330+
data = f"{values['-SE-DATA-']:<0{bits//4}.{bits//4}}"
331+
master_entropy = codecs.decode( data, 'hex_codec' )
332+
except Exception as exc:
333+
status = f"Invalid Hex for {bits}-bit extra seed entropy: {exc}"
334+
continue
335+
else:
336+
try:
337+
# SHA-512 stretch and possibly truncate
338+
stretch = hashlib.sha512()
339+
stretch.update( values['-SE-DATA-'].encode( 'UTF-8' ))
340+
master_entropy = stretch.digest()[:bits//8]
341+
except Exception as exc:
342+
status = f"Invalid data for {bits}-bit extra seed entropy: {exc}"
343+
continue
344+
345+
# Compute the Seed Entropy as hex. Will be 128-, 256- or 512-bit hex data.
346+
window['-SE-SEED-'].update( codecs.encode( master_entropy, 'hex_codec' ).decode( 'ascii' ))
347+
348+
# Compute the Master Secret Seed, from the supplied Seed Data and any extra Seed Entropy
349+
data = codecs.decode( window['-SD-SEED-'].get(), 'hex_codec' )
350+
entr = codecs.decode( window['-SE-SEED-'].get(), 'hex_codec' )
351+
seed = bytes( d ^ e for d,e in zip( data, entr ) )
352+
window['-SEED-'].update( codecs.encode( seed, 'hex_codec' ).decode( 'ascii' ))
353+
173354
# A target directory must be selected; use it. This is where any output will be written.
174355
# It should usually be a removable volume, but we do not check for this.
175356
try:
@@ -264,7 +445,18 @@ def app(
264445
def main( argv=None ):
265446
ap = argparse.ArgumentParser(
266447
description = "Create and output SLIP39 encoded Ethereum wallet(s) to a PDF file.",
267-
epilog = "" )
448+
formatter_class = argparse.RawDescriptionHelpFormatter,
449+
epilog = """\
450+
451+
A GUI App for creating SLIP-39 Mnemonic encoded cryptocurrency wallet seeds, either from secure
452+
randomness, or from pre-existing seed entropy, or recovered from prior SLIP-39 or BIP-39 encoded
453+
seed Mnemonics.
454+
455+
This can be useful for converting existing BIP-39 Mnemonic encoded seeds to more secure and
456+
recoverable SLIP-39 Mnemonic encoding.
457+
458+
"""
459+
)
268460
ap.add_argument( '-v', '--verbose', action="count",
269461
default=0,
270462
help="Display logging information." )

slip39/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version_info__ = ( 5, 2, 0 )
1+
__version_info__ = ( 5, 3, 0 )
22
__version__ = '.'.join( map( str, __version_info__ ))

0 commit comments

Comments
 (0)