Skip to content

Commit 41c0f6a

Browse files
committed
Support backup of BIP-39 mnemonic derived wallets
o Support 128-, 256- and 512-bit seeds, 20, 33 and 59 words mnemoncs o Correct wrong "default" Ethereum derivation path in docs (code ok) o Revise -v output of mnemonics to organize them in enumerated rows/cols
1 parent 3e27ef6 commit 41c0f6a

File tree

12 files changed

+585
-294
lines changed

12 files changed

+585
-294
lines changed

README.org

Lines changed: 306 additions & 227 deletions
Large diffs are not rendered by default.

README.pdf

7.24 KB
Binary file not shown.

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
4747
The python-slip39 project exists to assist in the safe creation and documentation of Ethereum HD
4848
Wallet accounts, with various SLIP-39 sharing parameters. It generates the new wallet seed,
49-
generates standard Ethereum account(s) (at derivation path =m/66'/40'/0'/0/0= by default) with
49+
generates standard Ethereum account(s) (at derivation path =m/44'/60'/0'/0/0= by default) with
5050
Ethereum wallet address and QR code, produces the required SLIP-39 phrases, and outputs a single PDF
5151
containing all the required printable cards to document the account.
5252
@@ -97,9 +97,9 @@
9797
| sort -r \\
9898
| python3 -m slip39.recovery \\
9999
| python3 -m slip39 --secret - --no-card -q
100-
2021-12-28 10:55:17 slip39 m/44'/60'/0'/0/0 : 0x68dD9B59D5dF605f4e9612E8b427Ab31187E2C54
100+
2021-12-28 10:55:17 slip39 ETH m/44'/60'/0'/0/0 : 0x68dD9B59D5dF605f4e9612E8b427Ab31187E2C54
101101
2021-12-28 10:55:18 slip39.recovery Recovered SLIP-39 secret with 4 (1st, 2nd, 7th, 8th) of 8 supplied mnemonics
102-
2021-12-28 10:55:18 slip39 m/44'/60'/0'/0/0 : 0x68dD9B59D5dF605f4e9612E8b427Ab31187E2C54
102+
2021-12-28 10:55:18 slip39 ETH m/44'/60'/0'/0/0 : 0x68dD9B59D5dF605f4e9612E8b427Ab31187E2C54
103103
104104
Here's an example of PDF containing the SLIP-39 recovery mnemonic cards produced:
105105

slip39/api_test.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from .generate_test import substitute, nonrandom_bytes
77
from .generate import account, create
8-
from .recovery import recover
8+
from .recovery import recover, recover_bip39
99

1010
SEED_XMAS_HEX = b"dd0e2f02b1f6c92a1a265561bc164135"
1111
SEED_XMAS = codecs.decode( SEED_XMAS_HEX, 'hex_codec' )
@@ -79,14 +79,14 @@ def test_recover():
7979
]
8080
)
8181
}
82-
recover( details.groups['one'][1] + details.groups['fren'][1][:3] ) == SEED_XMAS
82+
assert recover( details.groups['one'][1] + details.groups['fren'][1][:3] ) == SEED_XMAS
8383

8484
# Enough correct number of mnemonics must be provided (extras ignored)
8585
with pytest.raises(shamir_mnemonic.MnemonicError) as excinfo:
8686
recover( details.groups['one'][1] + details.groups['fren'][1][:2] )
8787
assert "Wrong number of mnemonics" in str(excinfo.value)
8888

89-
recover( details.groups['one'][1] + details.groups['fren'][1][:4] ) == SEED_XMAS
89+
assert recover( details.groups['one'][1] + details.groups['fren'][1][:4] ) == SEED_XMAS
9090

9191
# Invalid mnemonic phrases are rejected (one word changed)
9292
with pytest.raises(shamir_mnemonic.MnemonicError) as excinfo:
@@ -106,3 +106,71 @@ def test_recover():
106106
"academic acid academic axle crush swing purple violence teacher curly total equation clock mailman display husband tendency smug laundry disaster"
107107
])
108108
assert "Invalid set of mnemonics" in str(excinfo.value)
109+
110+
111+
@substitute( shamir_mnemonic.shamir, 'RANDOM_BYTES', nonrandom_bytes )
112+
def test_bip39():
113+
bip39seed = recover_bip39( 'zoo ' * 11 + 'wrong' )
114+
details = create(
115+
"bip39 recovery test", 2, dict( one = (1,1), two = (1,1), fam = (2,4), fren = (3,5) ),
116+
master_secret=bip39seed,
117+
)
118+
#import json
119+
#print( json.dumps( details.groups, indent=4 ))
120+
assert details.groups == {
121+
"one": (
122+
1,
123+
[
124+
"academic acid acrobat romp academic angel email prospect endorse strategy debris award strike frost actress facility legend safari pistol"
125+
" mouse hospital identify unwrap talent entrance trust cause ranked should impulse avoid fangs various radar dilemma indicate says rich work"
126+
" presence jerky glance hesitate huge depend tension loan tolerate news agree geology phrase random simple finger alarm depart inherit grin"
127+
]
128+
),
129+
"two": (
130+
1,
131+
[
132+
"academic acid beard romp acne floral cricket answer debris making decorate square withdraw empty decorate object artwork tracks rocky tolerate"
133+
" syndrome decorate predator sweater ordinary pecan plastic spew facility predator miracle change solution item lizard testify coal excuse lecture"
134+
" exercise hamster hand crystal rainbow indicate phantom require satisfy flame acrobat detect closet patent therapy overall muscle spill adjust unhappy"
135+
]
136+
),
137+
"fam": (
138+
2,
139+
[
140+
"academic acid ceramic roster acquire again tension ugly edge profile custody geology listen hazard smug branch adequate fishing simple adapt fancy"
141+
" hour method emperor tactics float quiet location satoshi guilt fantasy royal machine dictate squeeze devote oven eclipse writing level sheriff"
142+
" teacher purchase building veteran spirit woman realize width vanish scholar jewelry desktop stilt random rhyme debut premium theater",
143+
"academic acid ceramic scared acid space fantasy breathe true recover privacy tactics boring harvest punish swimming leader talent exchange diet"
144+
" enforce vanish volume organize coastal emperor change intend club scene intimate upgrade dragon burning lily huge market calcium forecast holiday"
145+
" merit method type ruler equip retailer pancake paces thorn worthy always story promise clock staff floral smart iris repair",
146+
"academic acid ceramic shadow acne rumor decent elder aspect lizard obesity friendly regular aircraft beyond military campus employer seafood cover"
147+
" ivory dough galaxy victim diminish average music cause behavior declare brave toxic visual academic include lilac repair morning rapids building"
148+
" kernel herald careful helpful move hawk flash glimpse seafood listen writing rocky browser change hybrid diet organize system wrote",
149+
"academic acid ceramic sister academic both legend raspy pecan mixed broken tenant critical again imply finance pacific single echo capital hesitate"
150+
" piece disease crush slush belong airline smug voice organize dryer standard emission curious charity swing pitch senior behavior vintage chemical"
151+
" cage editor rebuild costume adult ancestor erode steady makeup depart carpet level sympathy being soldier glimpse airport picture"
152+
]
153+
),
154+
"fren": (
155+
3,
156+
[
157+
"academic acid decision round academic academic academic academic academic academic academic academic academic academic academic academic academic"
158+
" academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic"
159+
" academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic academic"
160+
" academic academic academic academic academic academic academic aviation endless plastic",
161+
"academic acid decision scatter acid ugly raspy famous swimming else length gray raspy brother fake aunt auction premium military emphasis perfect"
162+
" surprise class suitable crunch famous burden military laundry inmate regret elder mixture tenant taught smirk voter process steady artist equip"
163+
" jury carve acrobat western cylinder gasoline artwork snapshot ancestor object cinema market species platform iris dragon dive medal",
164+
"academic acid decision shaft acid carbon credit cards rich living humidity peasant source triumph magazine ladle ruin ocean aspect curious round"
165+
" main evoke deny stadium zero discuss union strike pencil golden silent geology display wrap peanut listen aide learn juice decision plot bike example"
166+
" obesity ancient square pistol twice sister hour amuse human hobo hospital escape expect wildlife luck",
167+
"academic acid decision skin academic vanish olympic evoke gesture rumor unfair scroll grasp very steady include smell diploma package guest greatest"
168+
" firm humidity trial width priest class large photo sniff survive machine usher stick capacity heat improve predator float iris jacket soldier apart"
169+
" excuse garden cleanup realize permit dough script veteran crazy theater rival secret drink kernel lips pants",
170+
"academic acid decision snake acid vegan darkness bucket benefit therapy valuable impulse canyon swing distance vampire round losing twin medal treat"
171+
" amount fiction hush remind faint distance custody device believe campus guest preach mule exhaust regular short phrase column rescue steady float"
172+
" mixture testify taught fiction usher snake museum detailed agree intend inherit likely typical blimp symbolic prayer course"
173+
]
174+
)
175+
}
176+
assert recover( details.groups['one'][1][:] + details.groups['fren'][1][:3] ) == bip39seed

slip39/defaults.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,12 @@
5050
PAGE_MARGIN = 1/4 # Typical printers cannot print within 1/4" of edge
5151

5252
PAPER = 'Letter'
53+
54+
BITS = (128, 256, 512)
55+
BITS_DEFAULT = 128
56+
57+
MNEM_ROWS_COLS = {
58+
20: ( 7, 3), # 128-bit seed
59+
33: (11, 3), # 256-bit seed
60+
59: (12, 5), # 512-bit seed, eg. from BIP-39 (Unsupported on Trezor hardware wallet)
61+
}

slip39/generate.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import codecs
2+
import logging
23
import secrets
34
from collections import namedtuple
45
from typing import Dict, List, Sequence, Tuple
@@ -7,23 +8,57 @@
78

89
from shamir_mnemonic import generate_mnemonics
910

10-
from .defaults import PATH_ETH_DEFAULT
11+
from .defaults import PATH_ETH_DEFAULT, BITS_DEFAULT, MNEM_ROWS_COLS
12+
from .util import ordinal
1113

1214
RANDOM_BYTES = secrets.token_bytes
1315

16+
log = logging.getLogger( __package__ )
1417

15-
def random_secret() -> bytes:
16-
return RANDOM_BYTES( 16 )
18+
19+
def random_secret(
20+
seed_length = BITS_DEFAULT // 8
21+
) -> bytes:
22+
return RANDOM_BYTES( seed_length )
1723

1824

1925
Details = namedtuple( 'Details', ('name', 'group_threshold', 'groups', 'accounts') )
2026

2127

28+
def enumerate_mnemonic( mnemonic ):
29+
if isinstance( mnemonic, str ):
30+
mnemonic = mnemonic.split( ' ' )
31+
return dict(
32+
(i, f"{i+1:>2d} {w}")
33+
for i,w in enumerate( mnemonic )
34+
)
35+
36+
37+
def organize_mnemonic( mnemonic, rows=None, cols=None, label="" ):
38+
"""Given a SLIP-39 "word word ... word" or ["word", "word", ..., "word"] mnemonic, emit rows
39+
organized in the desired rows and cols (with defaults, if not provided). We return the fully
40+
formatted line, plus the list of individual words in that line.
41+
42+
"""
43+
num_words = enumerate_mnemonic( mnemonic )
44+
if not rows or not cols:
45+
rows,cols = MNEM_ROWS_COLS.get( len(num_words), (7, 3))
46+
for r in range( rows ):
47+
line = label if r == 0 else ' ' * len( label )
48+
words = []
49+
for c in range( cols ):
50+
word = num_words.get( c * rows + r )
51+
if word:
52+
words.append( word )
53+
line += f"{word:<13}"
54+
yield line,words
55+
56+
2257
def create(
2358
name: str,
2459
group_threshold: int,
2560
groups: Dict[str,Tuple[int, int]],
26-
master_secret: bytes = None,
61+
master_secret: bytes = None, # Default: 128-bit seeds
2762
passphrase: bytes = b"",
2863
iteration_exponent: int = 1,
2964
paths: Sequence[str] = None, # Default: PATH_ETH_DEFAULT
@@ -54,6 +89,17 @@ def create(
5489
g_name: (g_of, g_mnems)
5590
for (g_name,(g_of, _),g_mnems) in zip( g_names, g_dims, mnems )
5691
}
92+
if log.isEnabledFor( logging.INFO ):
93+
group_reqs = list(
94+
f"{g_nam}({g_of}/{len(g_mns)})" if g_of != len(g_mns) else f"{g_nam}({g_of})"
95+
for g_nam,(g_of,g_mns) in groups.items() )
96+
requires = f"Recover w/ {group_threshold} of {len(groups)} groups {', '.join(group_reqs)}"
97+
for g_n,(g_name,(g_of,g_mnems)) in enumerate( groups.items() ):
98+
log.info( f"{g_name}({g_of}/{len(g_mnems)}): {requires}" )
99+
for mn_n,mnem in enumerate( g_mnems ):
100+
for line,_ in organize_mnemonic( mnem, label=f"{ordinal(mn_n+1)} " ):
101+
log.info( f"{line}" )
102+
57103
return Details(name, group_threshold, groups, accounts)
58104

59105

@@ -70,9 +116,9 @@ def mnemonics(
70116
"""
71117
if master_secret is None:
72118
master_secret = random_secret()
73-
if len( master_secret ) != 16:
119+
if len( master_secret ) not in (16, 32, 64):
74120
raise ValueError(
75-
f"Only 128-bit (16 byte) seeds supported; {len(master_secret)*8}-bit master_secret supplied" )
121+
f"Only 128-, 256- and 512bit seeds supported; {len(master_secret)*8}-bit master_secret supplied" )
76122
return generate_mnemonics(
77123
group_threshold = group_threshold,
78124
groups = groups,

slip39/generate_test.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import codecs
22
import contextlib
3-
#import json
3+
import json
4+
import pytest
5+
6+
from eth_account.hdaccount.mnemonic import Mnemonic
47

58
import shamir_mnemonic
69

@@ -66,3 +69,54 @@ def test_share_2_of_groups():
6669
== shamir_mnemonic.combine_mnemonics(mnemonics[1] + mnemonics[2][2:4])
6770
assert mnemonics[2][0] == "academic acid ceramic roster academic photo daisy indicate salary hunting fancy guest taste diploma express usher regret equip install prevent"
6871
assert mnemonics[2][1] == "academic acid ceramic scared cubic episode metric intend symbolic overall employer course kind human criminal width game duration maiden favorite"
72+
73+
74+
@substitute( shamir_mnemonic.shamir, 'RANDOM_BYTES', nonrandom_bytes )
75+
@pytest.mark.parametrize("entropy,expected_BIP39,expected_seed,expected_SLIP39", [
76+
(
77+
"00000000000000000000000000000000",
78+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
79+
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
80+
"academic acid acrobat romp academic facility filter scared decision likely luxury acid aquatic campus submit cleanup style repair taste"
81+
" funding rebuild exotic busy perfect research curly snake exact seafood geology necklace axle fatigue valuable amuse spray pile union"
82+
" blue switch parking repeat pumps trend cleanup nuclear breathe guitar jacket party home science recover apart render artwork lair ambition market",
83+
),
84+
(
85+
"7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
86+
"legal winner thank year wave sausage worth useful legal winner thank yellow",
87+
"2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607",
88+
"academic acid acrobat romp acquire home transfer threaten year remove laundry editor midst climate research observe safari deadline staff"
89+
" twin downtown cause suitable airport total downtown image boundary tracks hospital brother squeeze phrase webcam grief deploy garbage climate"
90+
" lawsuit headset license aluminum hawk bedroom evidence thumb thorn shrimp finger easy exercise wisdom warn axis timber paper salon harvest luck",
91+
),
92+
])
93+
def test_bip39( entropy, expected_BIP39, expected_seed, expected_SLIP39 ):
94+
95+
# Generate a BIP39 Mnemonic seed. This is a one-way process; a BIP39 Mnemonic (and its
96+
# originating Entropy) is *not* recoverable from the Seed, as the derivation function is not
97+
# reversible. The process is Entropy --> BIP39 Mnemonic --> Seed
98+
m = Mnemonic("english")
99+
mnemonic = m.to_mnemonic(bytes.fromhex(entropy))
100+
assert m.is_mnemonic_valid(mnemonic)
101+
assert mnemonic == expected_BIP39
102+
103+
seed = Mnemonic.to_seed(mnemonic, passphrase="TREZOR")
104+
assert seed.hex() == expected_seed
105+
106+
# Now that we have the desired seed from the BIP39 process, we *can* save the 512-bit Seed via a
107+
# (large) SLIP39 Mnemonic.
108+
109+
mnemonics = shamir_mnemonic.generate_mnemonics(
110+
group_threshold = 2,
111+
groups = [(1, 1), (1, 1), (2, 5), (3, 6)],
112+
master_secret = seed,
113+
passphrase = b"",
114+
)
115+
116+
print( json.dumps( mnemonics, indent=4 ))
117+
assert len( mnemonics ) == 4
118+
assert shamir_mnemonic.combine_mnemonics(mnemonics[0] + mnemonics[2][0:2]) \
119+
== shamir_mnemonic.combine_mnemonics(mnemonics[1] + mnemonics[2][2:4]) \
120+
== seed
121+
assert all( len( m.split(' ')) == 59 for g in mnemonics for m in g )
122+
assert any( m == expected_SLIP39 for g in mnemonics for m in g )

slip39/layout.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from fpdf import FPDF
66

7-
from .defaults import FONTS
7+
from .defaults import FONTS, MNEM_ROWS_COLS
88

99

1010
class Region:
@@ -133,6 +133,7 @@ def element( self ):
133133
def card(
134134
card_size: Coordinate,
135135
card_margin: int,
136+
num_mnemonics: int = 20,
136137
):
137138
card = Box( 'card', 0, 0, card_size.x, card_size.y )
138139
card_interior = card.add_region_relative(
@@ -191,13 +192,15 @@ def card(
191192
Text( 'card-ETH', x1=0, y1=75/100, x2=1, y2=100/100, align='R' )
192193
)
193194

194-
rows,cols = 7,3
195+
assert num_mnemonics in MNEM_ROWS_COLS, \
196+
f"Invalid SLIP-39 mnemonic word count: {num_mnemonics}"
197+
rows,cols = MNEM_ROWS_COLS[num_mnemonics]
195198
for r in range( rows ):
196199
for c in range( cols ):
197200
card_mnemonics.add_region_proportional(
198201
Text( f"mnem-{c * rows + r}",
199202
x1=c/cols, y1=r/rows, x2=(c+1)/cols, y2=(r+1)/rows,
200-
font='mono', size_ratio=1/2 )
203+
font='mono', size_ratio=9/16 )
201204
)
202205
return card
203206

0 commit comments

Comments
 (0)