Skip to content

Commit dffe1c1

Browse files
committed
Merge branch 'feature-xpub'; v10.0.0
2 parents 9d97ef9 + c6e94af commit dffe1c1

File tree

13 files changed

+769
-259
lines changed

13 files changed

+769
-259
lines changed

README.org

Lines changed: 252 additions & 112 deletions
Large diffs are not rendered by default.

README.pdf

27.2 KB
Binary file not shown.

README.txt

Lines changed: 239 additions & 120 deletions
Large diffs are not rendered by default.

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
base58 >=2.0.1, <3
1+
click
2+
base58 >=2.0.1,<3
23
chacha20poly1305>=0.0.3
34
fpdf2 >=2.5.7,<3
45
hdwallet >=2.1, <3

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
'slip39-recovery = slip39.recovery.main:main',
149149
'slip39-generator = slip39.generator.main:main',
150150
'slip39-gui = slip39.gui.main:main',
151+
'slip39-cli = slip39.cli:cli',
151152
]
152153

153154
entry_points = {
@@ -160,6 +161,7 @@
160161
"slip39.recovery": "./slip39/recovery",
161162
"slip39.generator": "./slip39/generator",
162163
"slip39.gui": "./slip39/gui",
164+
"slip39.cli": "./slip39/cli",
163165
}
164166

165167
package_data = {

slip39/api.py

Lines changed: 114 additions & 11 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 HD 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 ):
@@ -697,9 +740,39 @@ def path_sequence(
697740
values[k] = next( viters[k], None )
698741

699742

700-
def cryptopaths_parser( cryptocurrency, edit=None ):
743+
def path_hardened( path ):
744+
"""Remove any non-hardened components from the end of path, eg:
745+
746+
>>> path_hardened( "m/84'/0'/0'/1/2" )
747+
("m/84'/0'/0'", 'm/1/2')
748+
>>> path_hardened( "m/1" )
749+
('m/', 'm/1')
750+
>>> path_hardened( "m/1'" )
751+
("m/1'", 'm/')
752+
>>> path_hardened( "m/" )
753+
('m/', 'm/')
754+
>>> path_hardened( "m/1/2/3'/4" )
755+
("m/1/2/3'", 'm/4')
756+
757+
Returns the two components as a tuple of two paths
758+
"""
759+
segs = path.split( '/' )
760+
# Always leaves the m/ on the hard path
761+
for hardened in range( 1, len( segs ) + 1 ):
762+
if not any( "'" in s for s in segs[hardened:] ):
763+
break
764+
else:
765+
log.debug( f"No non-hardened path segments in {path}" )
766+
767+
hard = 'm/' + '/'.join( segs[1:hardened] )
768+
soft = 'm/' + '/'.join( segs[hardened:] )
769+
return hard,soft
770+
771+
772+
def cryptopaths_parser( cryptocurrency, edit=None, hardened_defaults=False ):
701773
"""Generate a standard cryptopaths list, from the given sequnce of (<crypto>,<paths>) or
702-
"<crypto>[:<paths>]" cryptocurrencies (default: CRYPTO_PATHS).
774+
"<crypto>[:<paths>]" cryptocurrencies (default: CRYPTO_PATHS, optionally w/ only the hardened
775+
portion of the path, eg. omitting the trailing ../0/0).
703776
704777
Adjusts the provided derivation paths by an optional eg. "../-" path adjustment.
705778
@@ -716,6 +789,8 @@ def cryptopaths_parser( cryptocurrency, edit=None ):
716789
crypto = Account.supported( crypto )
717790
if paths is None:
718791
paths = Account.path_default( crypto )
792+
if hardened_defaults:
793+
paths,_ = path_hardened( paths )
719794
if edit:
720795
paths = path_edit( paths, edit )
721796
cryptopaths.append( (crypto,paths) )
@@ -950,15 +1025,43 @@ def account(
9501025
"""Generate an HD wallet Account from the supplied master_secret seed, at the given HD derivation
9511026
path, for the specified cryptocurrency.
9521027
1028+
If the master_secret is bytes, it is used as-is. If a str, then we generally expect it to be
1029+
hex. However, this is where we can detect alternatives like "{x,y,z}{pub,priv}key...". These
1030+
are identifiable by their prefix, which is incompatible with hex, so there is no ambiguity.
1031+
9531032
"""
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}" )
1033+
if isinstance( master_secret, bytes ) or all( c in string.hexdigits for c in master_secret ):
1034+
acct = Account(
1035+
crypto = crypto or 'ETH',
1036+
format = format,
1037+
)
1038+
acct.from_seed(
1039+
seed = master_secret,
1040+
path = path,
1041+
)
1042+
log.debug( f"Created {acct} from {len(master_secret)*8}-bit seed, at derivation path {path}" )
1043+
else:
1044+
# See if we recognize the prefix as a {x,y,z}pub... or .prv... Get the bound function for
1045+
# initializing the seed. Also, deduce the default format from the x/y/z+pub/prv.
1046+
default_fmt,from_method = {
1047+
'xpub': ('legacy', Account.from_xpubkey),
1048+
'xprv': ('legacy', Account.from_xprvkey),
1049+
'ypub': ('segwit', Account.from_xpubkey),
1050+
'yprv': ('segwit', Account.from_xprvkey),
1051+
'zpub': ('bech32', Account.from_xpubkey),
1052+
'zprv': ('bech32', Account.from_xprvkey),
1053+
}.get( master_secret[:4], (None,None) )
1054+
if from_method is None:
1055+
raise ValueError(
1056+
f"Only x/y/z + pub/prv prefixes supported; {master_secret[:8]+'...'!r} prefix supplied" )
1057+
if format is None:
1058+
format = default_fmt
1059+
acct = Account(
1060+
crypto = crypto or 'ETH',
1061+
format = format
1062+
)
1063+
from_method( acct, master_secret, path ) # It's an unbound method, so pass the instance
1064+
9621065
return acct
9631066

9641067

slip39/cli/__init__.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
2+
#
3+
# Python-slip39 -- Ethereum SLIP-39 Account Generation and Recovery
4+
#
5+
# Copyright (c) 2022, Dominion Research & Development Corp.
6+
#
7+
# Python-slip39 is free software: you can redistribute it and/or modify it under
8+
# the terms of the GNU General Public License as published by the Free Software
9+
# Foundation, either version 3 of the License, or (at your option) any later
10+
# version. It is also available under alternative (eg. Commercial) licenses, at
11+
# your option. See the LICENSE file at the top of the source tree.
12+
#
13+
# Python-slip39 is distributed in the hope that it will be useful, but WITHOUT
14+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
16+
#
17+
18+
import click
19+
import json
20+
import logging
21+
22+
from .. import addresses as slip39_addresses
23+
from ..util import log_cfg, log_level
24+
25+
"""
26+
Provide basic CLI access to the slip39 API.
27+
28+
Output generally defaults to JSON. Use -v for more details, and --no-json to emit standard text output instead.
29+
"""
30+
31+
32+
@click.group()
33+
@click.option('-v', '--verbose', count=True)
34+
@click.option('-q', '--quiet', count=True)
35+
@click.option( '--json/--no-json', default=True, help="Output JSON (the default)")
36+
def cli( verbose, quiet, json ):
37+
cli.verbosity = verbose - quiet
38+
log_cfg['level'] = log_level( cli.verbosity )
39+
logging.basicConfig( **log_cfg )
40+
if verbose or quiet:
41+
logging.getLogger().setLevel( log_cfg['level'] )
42+
cli.json = json
43+
cli.verbosity = 0 # noqa: E305
44+
cli.json = False
45+
46+
47+
@click.command()
48+
@click.option( "--crypto", help="The cryptocurrency address to generate (default: BTC)" )
49+
@click.option( "--paths", help="The HD wallet derivation path (default: the standard path for the cryptocurrency; if xpub, omits leading hardened segments by default)" )
50+
@click.option( "--secret", help="A seed or '{x,y,z}{pub,prv}...' x-public/private key to derive HD wallet addresses from" )
51+
@click.option( "--format", help="legacy, segwit, bech32 (default: standard for cryptocurrency or '{x,y,z}{pub/prv}...' key)" )
52+
@click.option( '--unbounded/--no-unbounded', default=False, help="Allow unbounded sequences of addresses")
53+
def addresses( crypto, paths, secret, format, unbounded ):
54+
if cli.json:
55+
click.echo( "[" )
56+
for i,(cry,pth,adr) in enumerate( slip39_addresses(
57+
master_secret = secret,
58+
crypto = crypto,
59+
paths = paths,
60+
format = format,
61+
allow_unbounded = unbounded
62+
)):
63+
if cli.json:
64+
if i:
65+
click.echo( "," )
66+
if cli.verbosity > 0:
67+
click.echo( f" [{json.dumps( cry )+',':6} {json.dumps( pth )+',':21} {json.dumps( adr )}]", nl=False )
68+
else:
69+
click.echo( f" {json.dumps( adr )}", nl=False )
70+
else:
71+
if cli.verbosity > 0:
72+
click.echo( f"{cry:5} {pth:20} {adr}" )
73+
else:
74+
click.echo( f"{adr}" )
75+
if cli.json:
76+
click.echo( "\n]" )
77+
78+
79+
cli.add_command( addresses )

slip39/cli/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import cli
2+
3+
cli()

slip39/generator/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def file_outputline(
196196

197197
def accountgroups_output(
198198
group,
199+
xpub = False,
199200
index = None,
200201
cipher = None,
201202
nonce = None,
@@ -241,7 +242,7 @@ def accountgroups_output(
241242

242243
# Emit the (optionally encrypted and indexed) accountgroup record.
243244
payload = json.dumps([
244-
(acct.crypto, acct.path, acct.address) if isinstance( acct, Account ) else acct
245+
(acct.crypto, acct.path, (acct.xpubkey if xpub else acct.address )) if isinstance( acct, Account ) else acct
245246
for acct in group
246247
])
247248
if cipher:

slip39/generator/main.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from . import chacha20poly1305, accountgroups_output, accountgroups_input
1111
from ..util import log_cfg, log_level, input_secure
12-
from ..defaults import BITS, BAUDRATE
12+
from ..defaults import BITS, BAUDRATE, CRYPTO_PATHS
1313
from .. import Account, cryptopaths_parser
1414
from ..api import accountgroups, RANDOM_BYTES
1515

@@ -87,6 +87,9 @@ def main( argv=None ):
8787
help=f"Specify crypto address formats: {', '.join( Account.FORMATS )}; default: " + ', '.join(
8888
f"{c}:{Account.address_format(c)}" for c in Account.CRYPTO_NAMES.values()
8989
))
90+
ap.add_argument( '--xpub', default=False, action='store_true',
91+
help="Output xpub... instead of cryptocurrency wallet address (and trim non-hardened default path segments)" )
92+
ap.add_argument( '--no-xpub', dest='xpub', action='store_false', help="Inhibit output of xpub (compatible w/ pre-v10.0.0)" )
9093
ap.add_argument( '-c', '--cryptocurrency', action='append',
9194
default=[],
9295
help="A crypto name and optional derivation path (default: \"ETH:{Account.path_default('ETH')}\"), optionally w/ ranges, eg: ETH:../0/-" )
@@ -135,10 +138,16 @@ def main( argv=None ):
135138
assert args.path.startswith( 'm/' ) or ( args.path.startswith( '..' ) and args.path.lstrip( '.' ).startswith( '/' )), \
136139
"A --path must start with 'm/', or '../', indicating intent to replace 1 or more trailing components of each cryptocurrency's derivation path"
137140

138-
# If any --format <crypto>:<format> address formats provided
141+
# If any --format <crypto>:<format> address formats provided. If not represented in
142+
# --cryptocurrency, add it; specifying a format implies interest in that cryptocurrency.
143+
if not args.cryptocurrency:
144+
args.cryptocurrency = list( CRYPTO_PATHS ) # the defaults, if none provided
139145
for cf in args.format:
140146
try:
141-
Account.address_format( *cf.split( ':' ) )
147+
crypto,format = cf.split( ':' )
148+
Account.address_format( crypto, format=format )
149+
if not any( k.startswith( crypto ) for k in args.cryptocurrency ):
150+
args.cryptocurrency.append( crypto )
142151
except Exception as exc:
143152
log.error( f"Invalid address format: {cf}: {exc}" )
144153
raise
@@ -252,7 +261,22 @@ def healthy_reset( file ):
252261

253262
# ...else...
254263
# Transmitting.
255-
cryptopaths = cryptopaths_parser( args.cryptocurrency, edit=args.path )
264+
265+
# What cryptocurrency addresses are requested? If --xpub, then for those with "default" paths,
266+
# trim off the non-hardened components. In other words, if
267+
#
268+
# --crypto BTC
269+
#
270+
# is specified, emit BTC "bc1..." bech32 addresses at m/84'/0'/0'/0/0, m/84'/0'/0'/0/1, ...
271+
#
272+
# However, if
273+
#
274+
# --xpub --crypto BTC
275+
#
276+
# is specified, emit BTC "zpub..." xpubkeys at m/84'/0'/0', m/84'/0'/1', ...
277+
cryptopaths = cryptopaths_parser(
278+
args.cryptocurrency, edit=args.path, hardened_defaults=args.xpub
279+
)
256280

257281
#
258282
# Set up serial device, if desired. We will attempt to send each record using hardware flow
@@ -343,6 +367,7 @@ def healthy_waiter( file ):
343367

344368
while not accountgroups_output(
345369
group = group,
370+
xpub = args.xpub,
346371
index = index if args.enumerated else None,
347372
cipher = cipher,
348373
nonce = nonce,

0 commit comments

Comments
 (0)