Skip to content

Commit f1d5359

Browse files
committed
Handle line-continuation prefixes in SLIP-39 seed recoveries
1 parent d23859c commit f1d5359

File tree

4 files changed

+145
-62
lines changed

4 files changed

+145
-62
lines changed

slip39/defaults.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,15 @@
9090
33: (11, 3), # 256-bit seed
9191
59: (12, 5), # 512-bit seed, eg. from BIP-39 (Unsupported on Trezor)
9292
}
93+
94+
# Separators for groups of Mnemonics, and those that indicate the continuation/last line of a Mnemonic phrase
9395
MNEM_PREFIX = {
94-
20: '=',
95-
33: '/\\',
96-
59: '/|\\',
96+
20: '{',
97+
33: '╭╰',
98+
59: '┌├└',
9799
}
100+
MNEM_LAST = '╰└{'
101+
MNEM_CONT = '╭┌├'
98102

99103
BAUDRATE = 115200
100104

slip39/gui/main.py

Lines changed: 134 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import math
66
import os
7+
import re
78
import subprocess
89

910
from itertools import islice
@@ -17,6 +18,7 @@
1718
from ..defaults import (
1819
GROUPS, GROUP_THRESHOLD_RATIO, MNEM_PREFIX, CRYPTO_PATHS, BITS,
1920
CARD_SIZES, CARD, PAPER_FORMATS, PAPER, WALLET_SIZES, WALLET,
21+
MNEM_CONT,
2022
)
2123

2224
log = logging.getLogger( __package__ )
@@ -29,6 +31,10 @@
2931
change_submits = True,
3032
font = font,
3133
)
34+
I_kwds_small = dict(
35+
change_submits = True,
36+
font = font_small,
37+
)
3238
T_kwds = dict(
3339
font = font,
3440
)
@@ -42,8 +48,9 @@
4248

4349
prefix = (20, 1)
4450
inputs = (40, 1)
45-
inlong = (128,1) # 512-bit seeds require 128 hex nibbles
51+
inlong = (128,1) # 512-bit seeds require 128 hex nibbles
4652
shorty = (10, 1)
53+
mnemos = (195,3)
4754

4855

4956
def groups_layout(
@@ -52,37 +59,54 @@ def groups_layout(
5259
groups,
5360
passphrase = None,
5461
cryptocurrency = None,
55-
wallet_pwd = None, # If False, disable capability
62+
wallet_pwd = None, # If False, disable capability
63+
simple = False, # Simplify layout by reducing complexity/choices
5664
):
5765
"""Return a layout for the specified number of SLIP-39 groups.
5866
5967
"""
6068

6169
group_body = [
62-
sg.Frame(
63-
'#', [[ sg.Column( [
64-
[ sg.Text( f'{g+1}', **T_kwds ) ]
65-
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
66-
], key='-GROUP-NUMBER-' ) ]], **F_kwds
67-
),
68-
sg.Frame(
69-
'Group Name; Recovery requires at least...', [[ sg.Column( [
70-
[ sg.Input( f'{g_name}', key=f'-G-NAME-{g}', **I_kwds ) ]
71-
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
72-
], key='-GROUP-NAMES-' ) ]], **F_kwds
73-
),
74-
sg.Frame(
75-
'# Needed', [[ sg.Column( [
76-
[ sg.Input( f'{g_need}', key=f'-G-NEED-{g}', size=shorty, **I_kwds ) ]
77-
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
78-
], key='-GROUP-NEEDS-' ) ]], **F_kwds
79-
),
80-
sg.Frame(
81-
'of # in Group', [[ sg.Column( [
82-
[ sg.Input( f'{g_size}', key=f'-G-SIZE-{g}', size=shorty, **I_kwds ) ]
83-
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
84-
], key='-GROUP-SIZES-' ) ]], **F_kwds
85-
),
70+
sg.Frame( '#', [
71+
[
72+
sg.Column( [
73+
[
74+
sg.Text( f'{g+1}', **T_kwds ),
75+
]
76+
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
77+
], key='-GROUP-NUMBER-' )
78+
]
79+
], **F_kwds ),
80+
sg.Frame( 'Group Name; Recovery requires at least...', [
81+
[
82+
sg.Column( [
83+
[
84+
sg.Input( f'{g_name}', key=f'-G-NAME-{g}', **I_kwds ),
85+
]
86+
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
87+
], key='-GROUP-NAMES-' )
88+
]
89+
], **F_kwds ),
90+
sg.Frame( '# Needed', [
91+
[
92+
sg.Column( [
93+
[
94+
sg.Input( f'{g_need}', key=f'-G-NEED-{g}', size=shorty, **I_kwds )
95+
]
96+
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
97+
], key='-GROUP-NEEDS-' )
98+
]
99+
], **F_kwds ),
100+
sg.Frame( 'of # in Group', [
101+
[
102+
sg.Column( [
103+
[
104+
sg.Input( f'{g_size}', key=f'-G-SIZE-{g}', size=shorty, **I_kwds )
105+
]
106+
for g,(g_name,(g_need,g_size)) in enumerate( groups.items() )
107+
], key='-GROUP-SIZES-' )
108+
]
109+
], **F_kwds ),
86110
]
87111

88112
if wallet_pwd is False:
@@ -104,7 +128,7 @@ def groups_layout(
104128
[
105129
sg.Text( "Seed Name(s): ", size=prefix, **T_kwds ),
106130
sg.Input( f"{', '.join( names )}", key='-NAMES-', size=inputs, **I_kwds ),
107-
sg.Text( "(comma-separated). Paper size: ", **T_kwds ),
131+
sg.Text( ", ... Paper size: ", **T_kwds ),
108132
] + [
109133
sg.Radio( f"{pf}", "PF", key=f"-PF-{pf}", default=(pf == PAPER), **B_kwds )
110134
for pf in PAPER_FORMATS
@@ -135,18 +159,18 @@ def groups_layout(
135159
], key='-SD-PASS-F-', visible=False, **F_kwds ),
136160
],
137161
[
138-
sg.Frame( 'From', [
162+
sg.Frame( 'From Mnemonic(s):', [
139163
[
140-
sg.Text( "Mnemonic(s): ", key='-SD-DATA-T-', size=prefix, **T_kwds ),
141-
sg.Multiline( "", key='-SD-DATA-', size=inlong, **I_kwds ),
164+
#sg.Text( "Mnemonic(s): ", key='-SD-DATA-T-', size=prefix, **T_kwds ),
165+
sg.Multiline( "", key='-SD-DATA-', size=mnemos, **I_kwds_small ),
142166
],
143167
], key='-SD-DATA-F-', visible=False, **F_kwds ),
144168
],
145169
[
146170
sg.Text( "Seed Data: ", size=prefix, **T_kwds ),
147171
sg.Text( "", key='-SD-SEED-', size=inlong, **T_kwds ),
148172
],
149-
], key='-SD-SEED-F-', **F_kwds ),
173+
], key='-SD-SEED-F-', **F_kwds ),
150174
],
151175
] + [
152176
[
@@ -178,7 +202,7 @@ def groups_layout(
178202
sg.Text( "", key='-SEED-', size=inlong, **T_kwds ),
179203
],
180204
[
181-
sg.Column( [
205+
sg.Column( [
182206
[
183207
sg.Text( "Requires recovery of: ", size=prefix, **T_kwds ),
184208
sg.Input( f"{group_threshold}", key='-THRESHOLD-', size=shorty, **I_kwds ),
@@ -265,7 +289,8 @@ def groups_layout(
265289
[
266290
sg.Frame( '8. SLIP-39 Recovery Mnemonics produced. These will be saved to the PDF on cards.', [
267291
[
268-
sg.Multiline( "", key='-MNEMONICS-'+sg.WRITE_ONLY_KEY, size=(195,6), font=font_small )
292+
sg.Multiline( "", key='-MNEMONICS-'+sg.WRITE_ONLY_KEY,
293+
size=mnemos, **I_kwds_small ) # noqa: E127
269294
]
270295
], key='-MNEMONICS-F-', **F_kwds ),
271296
],
@@ -282,6 +307,51 @@ def groups_layout(
282307
return layout
283308

284309

310+
def mnemonic_continuation( lines ):
311+
"""Filter out any prefixes consisting of word/space symbols followed by a single non-word/space
312+
symbol, before any number of Mnemonic word/space symbols:
313+
314+
Group 1 { word word ...
315+
Group 2 ╭ word word ...
316+
╰ word word ...
317+
Group 3 ┌ word word ...
318+
├ word word ...
319+
└ word word ...
320+
^^^^^^^^ ^ ^^^^^^^^^^...
321+
| | |
322+
word/digit/space* | word/space*
323+
|
324+
single non-word/digit/space
325+
326+
Any joining lines w/ a recognized MNEM_CONT prefix symbol (a UTF-8 square/curly bracket top or
327+
center segment) are concatenated. Any other non-prefixed or unrecognized word/space symbols are
328+
considered complete mnemonic lines.
329+
330+
"""
331+
mnemonic = []
332+
for li in lines:
333+
m = re.match( r"([\w\d\s]*[^\w\d\s])?([\w\s]*)", li )
334+
assert m, \
335+
f"Invalid BIP-39 or SLIP-39 Mnemonic [prefix:]phrase: {li}"
336+
pref,mnem = m.groups()
337+
if not pref and not mnem: # blank lines ignored
338+
continue
339+
mnemonic.append( mnem.strip() )
340+
continuation = pref and ( pref[-1] in MNEM_CONT )
341+
log.debug( f"Prefix: {pref!r:10}, Phrase: {mnem!r}" + ( "..." if continuation else "" ))
342+
if continuation:
343+
continue
344+
if mnemonic:
345+
phrase = ' '.join( mnemonic )
346+
log.info( f"Mnemonic phrase: {phrase!r}" )
347+
yield phrase
348+
mnemonic = []
349+
if mnemonic:
350+
phrase = ' '.join( mnemonic )
351+
log.info( f"Mnemonic phrase: {phrase!r}" )
352+
yield phrase
353+
354+
285355
def update_seed_data( event, window, values ):
286356
"""Respond to changes in the desired Seed Data source, and recover/generate and update the
287357
-SD-SEED- text, which is a Text field (not in values), and defines the size of the Seed in hex
@@ -320,17 +390,17 @@ def update_seed_data( event, window, values ):
320390
window['-SD-PASS-'].update( pwd )
321391
# And change visibility of Seed Data source controls
322392
if 'FIX' in update_seed_data.src:
323-
window['-SD-DATA-T-'].update( "Hex data: " )
393+
window['-SD-DATA-F-'].update( "Hex data: " )
324394
window['-SD-DATA-F-'].update( visible=True )
325395
window['-SD-PASS-C-'].update( visible=False )
326396
window['-SD-PASS-F-'].update( visible=False )
327397
elif 'BIP' in update_seed_data.src:
328-
window['-SD-DATA-T-'].update( "BIP-39 Mnemonic: " )
398+
window['-SD-DATA-F-'].update( "BIP-39 Mnemonic: " )
329399
window['-SD-DATA-F-'].update( visible=True )
330400
window['-SD-PASS-C-'].update( visible=False )
331401
window['-SD-PASS-F-'].update( visible=True )
332402
elif 'SLIP' in update_seed_data.src:
333-
window['-SD-DATA-T-'].update( "SLIP-39 Mnemonics: " )
403+
window['-SD-DATA-F-'].update( "SLIP-39 Mnemonics: " )
334404
window['-SD-DATA-F-'].update( visible=True )
335405
window['-SD-PASS-C-'].update( visible=True )
336406
window['-SD-PASS-F-'].update( visible=values['-SD-PASS-C-'] )
@@ -359,7 +429,7 @@ def update_seed_data( event, window, values ):
359429
window['-SD-PASS-F-'].update( visible=values['-SD-PASS-C-'] )
360430
try:
361431
seed_data = recover(
362-
mnemonics = dat.strip().split( '\n' ),
432+
mnemonics = list( mnemonic_continuation( dat.strip().split( '\n' ))),
363433
passphrase = pwd.strip().encode( 'UTF-8' )
364434
)
365435
bits = len( seed_data ) * 4
@@ -497,7 +567,7 @@ def compute_master_secret( window, values, n=0 ):
497567
498568
This is a critical feature -- without this visual confirmation, it is NOT POSSIBLE to trust any
499569
cryptocurrency seed generation algorithm.
500-
570+
501571
This function must have knowledge of the extra Seed Entropy settings, so it inspects the
502572
-SE-{NON/HEX}- checkbox values.
503573
"""
@@ -538,10 +608,15 @@ def update_seed_recovered( window, values, details, passphrase=None ):
538608
for g,(g_of,g_mnems) in details.groups.items() if details else []:
539609
for i,mnem in enumerate( g_mnems, start=1 ):
540610
mnemonics.append( mnem )
541-
# Display Mnemonics in rows of 20 words:
542-
# / word something ...
543-
# | another more ...
544-
# \ last line of mnemonic ...
611+
# Display Mnemonics in rows of 20, 33 or 59 words:
612+
# 1: single line mnemonic ...
613+
# or
614+
# 2/ word something another ...
615+
# \ last line of mnemonic ...
616+
# or
617+
# 3/ word something another ...
618+
# | another more ...
619+
# \ last line of mnemonic ...
545620
mset = mnem.split()
546621
for mri,mout in enumerate( chunker( mset, 20 )):
547622
p = MNEM_PREFIX.get( len(mset), '' )[mri:mri+1] or ':'
@@ -588,19 +663,19 @@ def app(
588663

589664
# Try to set a sane initial CWD (for saving generated files). If we start up in the standard
590665
# macOS App's "Container" directory for this App, ie.:
591-
#
666+
#
592667
# /Users/<somebody>/Library/Containers/ca.kundert.perry.SLIP39/Data
593-
#
668+
#
594669
# then we'll move upwards to the user's home directory. If we change the macOS App's Bundle ID,
595670
# this will change..
596671
cwd = os.getcwd()
597672
if cwd.endswith( '/Library/Containers/ca.kundert.perry.SLIP39/Data' ):
598673
cwd = cwd[:-48]
599674
sg.user_settings_set_entry( '-target folder-', cwd )
600675

601-
#
676+
#
602677
# If no name(s) supplied, try to get the User's full name.
603-
#
678+
#
604679
if not names:
605680
try:
606681
scutil = subprocess.run(
@@ -612,10 +687,10 @@ def app(
612687
print( repr( scutil ))
613688
assert scutil.returncode == 0 and scutil.stdout, \
614689
"'scutil' command failed, or no output returned"
615-
for l in scutil.stdout.split( '\n' ):
616-
if 'kCGSessionLongUserNameKey' in l:
690+
for li in scutil.stdout.split( '\n' ):
691+
if 'kCGSessionLongUserNameKey' in li:
617692
# eg.: " kCGSessionLongUserNameKey : Perry Kundert"
618-
full_name = l.split( ':' )[1].strip()
693+
full_name = li.split( ':' )[1].strip()
619694
log.info( f"Current user's full name: {full_name!r}" )
620695
names = [ full_name ]
621696
break
@@ -640,14 +715,15 @@ def app(
640715
status_error = False
641716
event = False
642717
events_termination = (sg.WIN_CLOSED, 'Exit',)
718+
events_ignored = ('-MNEMONICS-'+sg.WRITE_ONLY_KEY,)
643719
master_secret = None # default to produce randomly
644720
groups_recovered = None # The last SLIP-39 groups recovered from user input
645721
details = None # ..and the SLIP-39 details produced; make None to force SLIP-39 Mnemonic update
646722
cryptopaths = None
647723
while event not in events_termination:
648724
# Create window (for initial window.read()), or update status
649725
if window:
650-
window['-STATUS-'].update( f"{status or 'OK':.145}{'...' if len(status)>145 else ''}", font=font_bold if status_error else font )
726+
window['-STATUS-'].update( f"{status or 'OK':.145}{'...' if len(status or 'OK')>145 else ''}", font=font_bold if status_error else font )
651727
window['-SAVE-'].update( disabled=status_error )
652728
window['-RECOVERY-'].update( f"of {len(groups)}" )
653729
window['-SD-SEED-F-'].expand( expand_x=True )
@@ -665,13 +741,16 @@ def app(
665741
window = sg.Window( f"{', '.join( names or [ 'SLIP-39' ] )} Mnemonic Cards", layout )
666742
timeout = 0 # First time through, refresh immediately
667743

668-
status = None
669-
status_error = True
744+
# Block (except for first loop) and obtain current event and input values. Until we get a
745+
# new event, retain the current status
670746
event, values = window.read( timeout=timeout )
671-
logging.info( f"{event}, {values}" )
672-
if not values or event in events_termination:
747+
logging.debug( f"{event}, {values}" )
748+
if not values or event in events_termination or event in events_ignored:
673749
continue
674750

751+
status = None
752+
status_error = True
753+
675754
if event == '+' and len( groups ) < 16:
676755
# Add a SLIP39 Groups row (if not already at limit)
677756
g = len( groups )
@@ -741,7 +820,7 @@ def app(
741820
if g_rec != groups_recovered: # eg. group name or details changed
742821
log.info( "Updating SLIP-39 due to changing Groups" )
743822
groups_recovered = g_rec
744-
detail = None
823+
details = None
745824

746825
# Confirm the selected Group Threshold requirement
747826
try:

0 commit comments

Comments
 (0)