Skip to content

Commit b54bfc1

Browse files
committed
Working Account recovery from {x,y,z}pub
1 parent 9d97ef9 commit b54bfc1

File tree

3 files changed

+131
-12
lines changed

3 files changed

+131
-12
lines changed

slip39/api.py

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import math
2525
import re
2626
import secrets
27+
import string
2728
import warnings
2829

2930
from functools import wraps
@@ -442,18 +443,58 @@ def from_mnemonic( self, mnemonic: str, path: str = None ) -> "Account":
442443
self.from_path( path )
443444
return self
444445

446+
def from_xpubkey( self, xpubkey: str, path: str = None ) -> "Account":
447+
"""Derive the Account from the supplied xpubkey and (optionally) path; uses default
448+
derivation path for the Account address format, if None provided.
449+
450+
Since this xpubkey may have been generated at an arbitrary path, eg.
451+
452+
m/44'/60'/0'
453+
454+
any subsequent path provided, such as "m/0/0" will give us the address at
455+
effective path:
456+
457+
m/44'/60'/0'/0/0
458+
459+
However, if we ask for the path from this account, it will return:
460+
461+
m/0/0
462+
463+
It is impossible to correctly recover any "hardened" accounts from an xpubkey, such as
464+
"m/1'/0". These would need access to the private key material, which is missing.
465+
Therefore, the original account (or an xprivkey) would be required to access the desired
466+
path:
467+
468+
m/44'/60'/0'/1'/0
469+
470+
"""
471+
self.hdwallet.from_xpublic_key( xpubkey )
472+
self.from_path( path )
473+
return self
474+
475+
def from_xprvkey( self, xprvkey: str, path: str = None ) -> "Account":
476+
self.hdwallet.from_xpublic_key( xprvkey )
477+
self.from_path( path )
478+
return self
479+
445480
def from_path( self, path: str = None ) -> "Account":
446481
"""Change the Account to derive from the provided path.
447482
448483
If a partial path is provided (eg "...1'/0/3"), then use it to replace the given segments in
449484
current (or the default) account path, leaving the remainder alone.
450485
486+
If the derivation path is empty (only "m/") then leave the Account at clean_derivation state
487+
451488
"""
452489
from_path = self.path or Account.path_default( self.crypto, self.format )
453490
if path:
454491
from_path = path_edit( from_path, path )
455492
self.hdwallet.clean_derivation()
456-
self.hdwallet.from_path( from_path )
493+
# Valid DH wallet derivation paths always start with "m/"
494+
if not ( from_path and len( from_path ) >= 2 and from_path.startswith( "m/" ) ):
495+
raise ValueError( f"Unrecognized HD wallet derivation path: {from_path!r}" )
496+
if len( from_path ) > 2:
497+
self.hdwallet.from_path( from_path )
457498
return self
458499

459500
@property
@@ -515,10 +556,12 @@ def path( self ):
515556
@property
516557
def key( self ):
517558
return self.hdwallet.private_key()
559+
prvkey = key
518560

519561
@property
520562
def xkey( self ):
521563
return self.hdwallet.xprivate_key()
564+
xprvkey = xkey
522565

523566
@property
524567
def pubkey( self ):
@@ -950,15 +993,43 @@ def account(
950993
"""Generate an HD wallet Account from the supplied master_secret seed, at the given HD derivation
951994
path, for the specified cryptocurrency.
952995
996+
If the master_secret is bytes, it is used as-is. If a str, then we generally expect it to be
997+
hex. However, this is where we can detect alternatives like "{x,y,z}{pub,priv}key...". These
998+
are identifiable by their prefix, which is incompatible with hex, so there is no ambiguity.
999+
9531000
"""
954-
acct = Account(
955-
crypto = crypto or 'ETH',
956-
format = format,
957-
).from_seed(
958-
seed = master_secret,
959-
path = path,
960-
)
961-
log.debug( f"Created {acct} from {len(master_secret)*8}-bit seed, at derivation path {path}" )
1001+
if isinstance( master_secret, bytes ) or all( c in string.hexdigits for c in master_secret ):
1002+
acct = Account(
1003+
crypto = crypto or 'ETH',
1004+
format = format,
1005+
)
1006+
acct.from_seed(
1007+
seed = master_secret,
1008+
path = path,
1009+
)
1010+
log.debug( f"Created {acct} from {len(master_secret)*8}-bit seed, at derivation path {path}" )
1011+
else:
1012+
# See if we recognize the prefix as a {x,y,z}pub... or .prv... Get the bound function for
1013+
# initializing the seed. Also, deduce the default format from the x/y/z+pub/prv.
1014+
default_fmt,from_method = {
1015+
'xpub': ('legacy', Account.from_xpubkey),
1016+
'xprv': ('legacy', Account.from_xprvkey),
1017+
'ypub': ('segwit', Account.from_xpubkey),
1018+
'yprv': ('segwit', Account.from_xprvkey),
1019+
'zpub': ('bech32', Account.from_xpubkey),
1020+
'zprv': ('bech32', Account.from_xprvkey),
1021+
}.get( master_secret[:4], (None,None) )
1022+
if from_method is None:
1023+
raise ValueError(
1024+
f"Only x/y/z + pub/prv prefixes supported; {master_secret[:8]+'...'!r} prefix supplied" )
1025+
if format is None:
1026+
format = default_fmt
1027+
acct = Account(
1028+
crypto = crypto or 'ETH',
1029+
format = format
1030+
)
1031+
from_method( acct, master_secret, path ) # It's an unbound method, so pass the instance
1032+
9621033
return acct
9631034

9641035

slip39/recovery_test.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,58 @@ def test_recover_bip39_vectors():
315315
path = t.get( 'path' ),
316316
format = format
317317
)
318-
# Finally, generate the account's address or xpubkey
319-
addresses = [ acct.address, acct.xpubkey ]
318+
# Finally, generate the account's address and xpubkey, and see that one of them match the
319+
# address in the test case. Only the address/xpubkey is in the test cases, but lets get the
320+
# compressed public key as well for comparision w/ the xpubkey derived account...
321+
addresses = [ acct.address, acct.pubkey, acct.xpubkey ]
320322
assert address in addresses, \
321323
f"row {i+1}: BTC account {address} not in {addresses!r} for entropy {entropy} ==> {master_secret}"
324+
log.info( f"BIP-44 HD path {acct.path:36}: {commas( addresses )}" )
325+
326+
# OK, we recovered the same address/xpubkey as the test case did. Now, if the path ends in
327+
# at least one layer of non-hardened path, we should be able to:
328+
#
329+
# - remove one (or more) path segments, back to a hardened path component
330+
# - generate the xpubkey for that hardened path
331+
# - recover a new account using that xpubkey
332+
# - generate the same sub-account from it, using the remainder of the path.
333+
# - confirm that the address generated by both the original (at full path) and the new
334+
# account (from its xpubkey at hardened path + the remaining path) are identical (xpub
335+
# will be different, but will produce the same address
336+
log.info( f"Testing recovery from xpub, generating sub-addresses for {acct.path}" )
337+
path = acct.path.split('/')
338+
# Always leaves the m/ on the hard path
339+
for hardened in range( 1, len( path )):
340+
if not any( "'" in seg for seg in path[hardened:] ):
341+
break
342+
else:
343+
log.debug( f" - No non-hardened path segments in {acct.path}" )
344+
continue
345+
346+
hard = 'm/' + '/'.join( path[1:hardened] )
347+
soft = 'm/' + '/'.join( path[hardened:] )
348+
log.debug( f"BIP-44 HD path hard: {hard:14}, soft: {soft:8}" )
349+
acct.from_path( hard )
350+
xpubkey = acct.xpubkey
351+
352+
# Now recover from that xpubkey, and use the soft remainder of the path; deduces the addresses format
353+
acct_xpub = account(
354+
master_secret = xpubkey,
355+
crypto = 'BTC',
356+
path = soft,
357+
)
358+
359+
# Finally, generate the xpub-derived account's address or xpubkey, and see that one of them
360+
# match the address in the test case. NOTE: the xpubkey will differ, but the address will
361+
# match. This is because in the presence of the full secret key, both the public and the
362+
# private SECP256k1 curve points are maintained as the HD wallet path is parsed; when only
363+
# the public key is available, only it is modified; the secret key information is
364+
# unavailable, and therefore not modified as each path component is parsed.
365+
addresses_xpub = [ acct_xpub.address, acct_xpub.pubkey, acct_xpub.xpubkey ]
366+
log.info( f"BIP-44 HD path hard: {hard:14}, soft: {soft:8}: {commas( addresses_xpub )}" )
367+
368+
assert acct_xpub.address in addresses, \
369+
f"row {i+1}: BTC account {acct_xpub.address} not in original account's {addresses!r} for xpub-derived account"
322370

323371

324372
def test_util():

slip39/version.py

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

0 commit comments

Comments
 (0)