Skip to content

Commit c6e94af

Browse files
committed
Working account generation and recover to/from xpubkeys
1 parent b54bfc1 commit c6e94af

File tree

13 files changed

+651
-260
lines changed

13 files changed

+651
-260
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: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ def from_path( self, path: str = None ) -> "Account":
490490
if path:
491491
from_path = path_edit( from_path, path )
492492
self.hdwallet.clean_derivation()
493-
# Valid DH wallet derivation paths always start with "m/"
493+
# Valid HD wallet derivation paths always start with "m/"
494494
if not ( from_path and len( from_path ) >= 2 and from_path.startswith( "m/" ) ):
495495
raise ValueError( f"Unrecognized HD wallet derivation path: {from_path!r}" )
496496
if len( from_path ) > 2:
@@ -740,9 +740,39 @@ def path_sequence(
740740
values[k] = next( viters[k], None )
741741

742742

743-
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 ):
744773
"""Generate a standard cryptopaths list, from the given sequnce of (<crypto>,<paths>) or
745-
"<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).
746776
747777
Adjusts the provided derivation paths by an optional eg. "../-" path adjustment.
748778
@@ -759,6 +789,8 @@ def cryptopaths_parser( cryptocurrency, edit=None ):
759789
crypto = Account.supported( crypto )
760790
if paths is None:
761791
paths = Account.path_default( crypto )
792+
if hardened_defaults:
793+
paths,_ = path_hardened( paths )
762794
if edit:
763795
paths = path_edit( paths, edit )
764796
cryptopaths.append( (crypto,paths) )

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)