Subwallets, Part 1: basic framework and subwallet generation

Beginning with this commit, every MMGen wallet now has a two sets of associated
subwallets with “long“ and “short” seeds.

MMGen wallets and subwallets are functionally equivalent and externally
indistinguishable.  This has benefits, especially for real-world security, as
well as drawbacks.  For more information, see the `mmgen-subwalletgen` help
screen: https://github.com/mmgen/mmgen/wiki/subwalletgen-[MMGen-command-help]

This patch provides subwallet generation functionality and subseed display
utilities.  Support for transaction signing and address generation using a
subwallet's parent wallet will be added in a forthcoming patch.

Examples:

    # Create a bogus wallet in mnemonic format for testing purposes:
    $ echo $(yes bee | head -n24) > bogus.mmwords

    # List the wallet's first five subseed pairs:
    $ mmgen-tool list_subseeds 1-5 wallet=bogus.mmwords
    Parent Seed: DF449DA4 (256 bits)

     Long Subseeds     Short Subseeds
     -------------     --------------
      1L: FC9A8735       1S: 930E1AD5
      2L: 62B02F54       2S: DF14AB49
      3L: 9E884E99       3S: AD3ABD98
      4L: DB595AE1       4S: 3E885EC4
      5L: 36D5A0D1       5S: 30D66FF5

    # Generate the 5th short (128-bit) subwallet from the wallet:
    $ mmgen-subwalletgen bogus.mmwords 5S

    # Same as above, but output subwallet to mnemonic (seed phrase) format:
    $ mmgen-subwalletgen -o mn bogus.mmwords 5S
    ...
    Mnemonic data written to file '30D66FF5[128].mmwords'

    # View the subwallet's seed phrase:
    $ cat 30D66FF5[128].mmwords
    object capture field heart page observe road bond mother loser really army

    # Generate 10 addresses from the subwallet seed phrase:
    $ mmgen-addrgen 30D66FF5[128].mmwords 1-10
    ...
    Addresses written to file '30D66FF5[1-10].addrs'
This commit is contained in:
The MMGen Project 2019-05-12 12:29:35 +00:00
commit 7538a9460e
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
16 changed files with 527 additions and 11 deletions

24
cmds/mmgen-subwalletgen Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
# Copyright (C)2013-2019 The MMGen Project <mmgen@tuta.io>
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
"""
mmgen-subwalletgen: Generate a subwallet from an MMGen deterministic wallet
"""
from mmgen.main import launch
launch("subwalletgen")

View file

@ -41,6 +41,9 @@
# Set the default hash preset: # Set the default hash preset:
# hash_preset 3 # hash_preset 3
# Set the default number of subseeds:
# subseeds 100
# Set the default number of entropy characters to get from user. # Set the default number of entropy characters to get from user.
# Must be between 10 and 80. # Must be between 10 and 80.
# A value of 0 disables user entropy, but this is not recommended: # A value of 0 disables user entropy, but this is not recommended:

View file

@ -28,6 +28,7 @@ from mmgen.opts import opt
from mmgen.util import * from mmgen.util import *
def help_notes(k): def help_notes(k):
from mmgen.obj import SubSeedIdxRange
from mmgen.seed import SeedSource from mmgen.seed import SeedSource
from mmgen.tx import MMGenTX from mmgen.tx import MMGenTX
def fee_spec_letters(use_quotes=False): def fee_spec_letters(use_quotes=False):
@ -40,6 +41,42 @@ def help_notes(k):
return { return {
'rel_fee_desc': MMGenTX().rel_fee_desc, 'rel_fee_desc': MMGenTX().rel_fee_desc,
'fee_spec_letters': fee_spec_letters(), 'fee_spec_letters': fee_spec_letters(),
'subwallet': """
SUBWALLETS:
Subwallets (subseeds) are specified by a "Subseed Index" consisting of:
a) an integer in the range 1-{}, plus
b) an optional single letter, 'L' or 'S'
The letter designates the length of the subseed. If omitted, 'L' is assumed.
Long ('L') subseeds are the same length as their parent wallet's seed
(typically 256 bits), while short ('S') subseeds are always 128-bit.
The long and short subseeds for a given index are derived independently,
so both may be used.
MMGen has no notion of "depth", and to an outside observer subwallets are
identical to ordinary wallets. This is a feature rather than a bug, as it
denies an attacker any way of knowing whether a given wallet has a parent.
Since subwallets are just wallets, they may be used to generate other
subwallets, leading to hierarchies of arbitrary depth. However, this is
inadvisable in practice for two reasons: Firstly, it creates accounting
complexity, requiring the user to independently keep track of a derivation
tree. More importantly, however, it leads to the danger of Seed ID
collisions between subseeds at different levels of the hierarchy, as
MMGen checks and avoids ID collisions only among sibling subseeds.
An exception to this caveat would be a multi-user setup where sibling
subwallets are distributed to different users as their default wallets.
Since the subseeds derived from these subwallets are private to each user,
Seed ID collisions among them doesn't present a problem.
A safe rule of thumb, therefore, is for *each user* to derive all of his/her
subwallets from a single parent. This leaves each user with a total of two
million subwallets, which should be enough for most practical purposes.
""".strip().format(SubSeedIdxRange.max_idx),
'passwd': """ 'passwd': """
PASSPHRASE NOTE: PASSPHRASE NOTE:

View file

@ -39,3 +39,4 @@ class BadMMGenTxID(Exception): mmcode = 4
class IllegalWitnessFlagValue(Exception): mmcode = 4 class IllegalWitnessFlagValue(Exception): mmcode = 4
class TxHexParseError(Exception): mmcode = 4 class TxHexParseError(Exception): mmcode = 4
class TxHexMismatch(Exception): mmcode = 4 class TxHexMismatch(Exception): mmcode = 4
class SubSeedNonceRangeExceeded(Exception): mmcode = 4

View file

@ -73,6 +73,7 @@ class g(object):
debug_opts = False debug_opts = False
debug_rpc = False debug_rpc = False
debug_addrlist = False debug_addrlist = False
debug_subseed = False
quiet = False quiet = False
no_license = False no_license = False
force_256_color = False force_256_color = False
@ -128,7 +129,7 @@ class g(object):
'quiet','tx_confs','tx_fee_adj','key_generator' ) 'quiet','tx_confs','tx_fee_adj','key_generator' )
# user opt sets global var: # user opt sets global var:
opt_sets_global = ( 'use_internal_keccak_module', ) opt_sets_global = ( 'use_internal_keccak_module','subseeds' )
# 'long' opts - opt sets global var # 'long' opts - opt sets global var
common_opts = ( common_opts = (
@ -155,7 +156,7 @@ class g(object):
cfg_file_opts = ( cfg_file_opts = (
'color','debug','hash_preset','http_timeout','no_license','rpc_host','rpc_port', 'color','debug','hash_preset','http_timeout','no_license','rpc_host','rpc_port',
'quiet','tx_fee_adj','usr_randchars','testnet','rpc_user','rpc_password', 'quiet','tx_fee_adj','usr_randchars','testnet','rpc_user','rpc_password',
'daemon_data_dir','force_256_color','regtest', 'daemon_data_dir','force_256_color','regtest','subseeds',
'btc_max_tx_fee','ltc_max_tx_fee','bch_max_tx_fee','eth_max_tx_fee', 'btc_max_tx_fee','ltc_max_tx_fee','bch_max_tx_fee','eth_max_tx_fee',
'eth_mainnet_chain_name','eth_testnet_chain_name', 'eth_mainnet_chain_name','eth_testnet_chain_name',
'max_tx_file_size','max_input_size' 'max_tx_file_size','max_input_size'
@ -175,6 +176,7 @@ class g(object):
'MMGEN_DEBUG_RPC', 'MMGEN_DEBUG_RPC',
'MMGEN_DEBUG_ADDRLIST', 'MMGEN_DEBUG_ADDRLIST',
'MMGEN_DEBUG_UTF8', 'MMGEN_DEBUG_UTF8',
'MMGEN_DEBUG_SUBSEED',
'MMGEN_QUIET', 'MMGEN_QUIET',
'MMGEN_FORCE_256_COLOR', 'MMGEN_FORCE_256_COLOR',
'MMGEN_MIN_URANDCHARS', 'MMGEN_MIN_URANDCHARS',
@ -201,6 +203,7 @@ class g(object):
seed_lens = 128,192,256 seed_lens = 128,192,256
scramble_hash_rounds = 10 scramble_hash_rounds = 10
subseeds = 100
mmenc_ext = 'mmenc' mmenc_ext = 'mmenc'
salt_len = 16 salt_len = 16

View file

@ -22,7 +22,7 @@ main.py - Script launcher for the MMGen suite
def launch(mod): def launch(mod):
if mod in ('walletgen','walletchk','walletconv','passchg'): if mod in ('walletgen','walletchk','walletconv','passchg','subwalletgen'):
mod = 'wallet' mod = 'wallet'
if mod == 'keygen': mod = 'addrgen' if mod == 'keygen': mod = 'addrgen'

View file

@ -31,12 +31,14 @@ nargs = 1
iaction = 'convert' iaction = 'convert'
oaction = 'convert' oaction = 'convert'
do_bw_note = True do_bw_note = True
do_sw_note = False
invoked_as = { invoked_as = {
'mmgen-walletgen': 'gen', 'mmgen-walletgen': 'gen',
'mmgen-walletconv': 'conv', 'mmgen-walletconv': 'conv',
'mmgen-walletchk': 'chk', 'mmgen-walletchk': 'chk',
'mmgen-passchg': 'passchg', 'mmgen-passchg': 'passchg',
'mmgen-subwalletgen': 'subgen',
}[g.prog_name] }[g.prog_name]
# full: defhHiJkKlLmoOpPqrSvz- # full: defhHiJkKlLmoOpPqrSvz-
@ -58,6 +60,11 @@ elif invoked_as == 'passchg':
opt_filter = 'efhdiHkKOlLmpPqrSvz-' opt_filter = 'efhdiHkKOlLmpPqrSvz-'
iaction = 'input' iaction = 'input'
do_bw_note = False do_bw_note = False
elif invoked_as == 'subgen':
desc = 'Generate a subwallet from an {pnm} wallet'
opt_filter = 'dehHiJkKlLmoOpPqrSvz-' # omitted: f
usage = '[opts] [infile] <Subseed Index>'
do_sw_note = True
opts_data = { opts_data = {
'text': { 'text': {
@ -96,7 +103,7 @@ opts_data = {
""", """,
'notes': """ 'notes': """
{n_pw}{n_bw} {n_sw}{n_pw}{n_bw}
FMT CODES: FMT CODES:
@ -111,6 +118,7 @@ FMT CODES:
), ),
'notes': lambda s: s.format( 'notes': lambda s: s.format(
f='\n '.join(SeedSource.format_fmt_codes().splitlines()), f='\n '.join(SeedSource.format_fmt_codes().splitlines()),
n_sw=('',help_notes('subwallet')+'\n\n')[do_sw_note],
n_pw=help_notes('passwd'), n_pw=help_notes('passwd'),
n_bw=('','\n\n'+help_notes('brainwallet'))[do_bw_note] n_bw=('','\n\n'+help_notes('brainwallet'))[do_bw_note]
) )
@ -122,11 +130,15 @@ cmd_args = opts.init(opts_data,opt_filter=opt_filter)
if opt.label: if opt.label:
opt.label = MMGenWalletLabel(opt.label,msg="Error in option '--label'") opt.label = MMGenWalletLabel(opt.label,msg="Error in option '--label'")
if invoked_as == 'subgen':
from mmgen.obj import SubSeedIdx
ss_idx = SubSeedIdx(cmd_args.pop())
sf = get_seed_file(cmd_args,nargs,invoked_as=invoked_as) sf = get_seed_file(cmd_args,nargs,invoked_as=invoked_as)
if not invoked_as == 'chk': do_license_msg() if not invoked_as == 'chk': do_license_msg()
if invoked_as in ('conv','passchg'): if invoked_as in ('conv','passchg','subgen'):
m1 = green('Processing input wallet') m1 = green('Processing input wallet')
m2 = yellow(' (default wallet)') if sf and os.path.dirname(sf) == g.data_dir else '' m2 = yellow(' (default wallet)') if sf and os.path.dirname(sf) == g.data_dir else ''
msg(m1+m2) msg(m1+m2)
@ -139,9 +151,18 @@ if invoked_as == 'chk':
# TODO: display creation date # TODO: display creation date
sys.exit(0) sys.exit(0)
if invoked_as in ('conv','passchg'): if invoked_as in ('conv','passchg','subgen'):
gmsg('Processing output wallet') gmsg('Processing output wallet')
if invoked_as == 'subgen':
msg_r('{} {} of {}...'.format(
green('Generating subseed'),
ss_idx.hl(),
ss_in.seed.sid.hl(),
))
msg('\b\b\b => {}'.format(ss_in.seed.subseed(ss_idx).sid.hl()))
ss_out = SeedSource(seed=ss_in.seed.subseed(ss_idx).data)
else:
ss_out = SeedSource(ss=ss_in,passchg=invoked_as=='passchg') ss_out = SeedSource(ss=ss_in,passchg=invoked_as=='passchg')
if invoked_as == 'gen': if invoked_as == 'gen':

View file

@ -361,6 +361,10 @@ class MMGenRange(tuple,InitErrors,MMGenObject):
def items(self): def items(self):
return list(self.iterate()) return list(self.iterate())
class SubSeedIdxRange(MMGenRange):
min_idx = 1
max_idx = 1000000
class UnknownCoinAmt(Decimal): pass class UnknownCoinAmt(Decimal): pass
class BTCAmt(Decimal,Hilite,InitErrors): class BTCAmt(Decimal,Hilite,InitErrors):
@ -534,8 +538,8 @@ class SeedID(str,Hilite,InitErrors):
cls.arg_chk(cls,on_fail) cls.arg_chk(cls,on_fail)
try: try:
if seed: if seed:
from mmgen.seed import Seed from mmgen.seed import Seed,SubSeed
assert type(seed) == Seed,'not a Seed instance' assert type(seed) in (Seed,SubSeed),'not a Seed or SubSeed instance'
from mmgen.util import make_chksum_8 from mmgen.util import make_chksum_8
return str.__new__(cls,make_chksum_8(seed.data)) return str.__new__(cls,make_chksum_8(seed.data))
elif sid: elif sid:
@ -547,6 +551,30 @@ class SeedID(str,Hilite,InitErrors):
m = "{!r}: value cannot be converted to SeedID ({})" m = "{!r}: value cannot be converted to SeedID ({})"
return cls.init_fail(m.format(seed or sid,e.args[0]),on_fail) return cls.init_fail(m.format(seed or sid,e.args[0]),on_fail)
class SubSeedIdx(str,Hilite,InitErrors):
color = 'red'
trunc_ok = False
def __new__(cls,s,on_fail='die'):
if type(s) == cls: return s
cls.arg_chk(cls,on_fail)
try:
assert issubclass(type(s),str),'not a string or string subclass'
idx = s[:-1] if s[-1] in 'SsLl' else s
from mmgen.util import is_int
assert is_int(idx),"valid format: an integer, plus optional letter 'S','s','L' or 'l'"
idx = int(idx)
assert idx >= SubSeedIdxRange.min_idx, 'subseed index < {:,}'.format(SubSeedIdxRange.min_idx)
assert idx <= SubSeedIdxRange.max_idx, 'subseed index > {:,}'.format(SubSeedIdxRange.max_idx)
sstype,ltr = ('short','S') if s[-1] in 'Ss' else ('long','L')
me = str.__new__(cls,str(idx)+ltr)
me.idx = idx
me.type = sstype
return me
except Exception as e:
m = "{!r}: value cannot be converted to {} ({})"
return cls.init_fail(m.format(s,cls.__name__,e.args[0]),on_fail)
class MMGenID(str,Hilite,InitErrors,MMGenObject): class MMGenID(str,Hilite,InitErrors,MMGenObject):
color = 'orange' color = 'orange'
width = 0 width = 0

View file

@ -45,7 +45,7 @@ def is_mnemonic(s):
opt.quiet = oq_save opt.quiet = oq_save
return ret return ret
class Seed(MMGenObject): class SeedBase(MMGenObject):
data = MMGenImmutableAttr('data',bytes,typeconv=False) data = MMGenImmutableAttr('data',bytes,typeconv=False)
hexdata = MMGenImmutableAttr('hexdata',str,typeconv=False) hexdata = MMGenImmutableAttr('hexdata',str,typeconv=False)
@ -64,6 +64,114 @@ class Seed(MMGenObject):
self.sid = SeedID(seed=self) self.sid = SeedID(seed=self)
self.length = len(seed_bin) * 8 self.length = len(seed_bin) * 8
class Seed(SeedBase):
def __init__(self,seed_bin=None):
from collections import OrderedDict
self.subseeds = { 'long': OrderedDict(), 'short': OrderedDict() }
SeedBase.__init__(self,seed_bin=seed_bin)
def subseed(self,ss_idx_in):
ss_idx = SubSeedIdx(ss_idx_in)
if ss_idx.idx > len(self.subseeds['long']):
self.gen_subseeds(ss_idx.idx)
sid = list(self.subseeds[ss_idx.type].keys())[ss_idx.idx-1]
idx,nonce = self.subseeds[ss_idx.type][sid]
assert idx == ss_idx.idx, "{} != {}: subseed list idx does not match subseed idx!".format(idx,ss_idx.idx)
return SubSeed(self,idx,nonce,length=ss_idx.type)
def existing_subseed_by_seed_id(self,sid):
for k in ('long','short'):
if sid in self.subseeds[k]:
idx,nonce = self.subseeds[k][sid]
return SubSeed(self,idx,nonce,length=k)
def subseed_by_seed_id(self,sid,last_idx=g.subseeds):
seed = self.existing_subseed_by_seed_id(sid)
if seed: return seed
if len(self.subseeds['long']) >= last_idx:
return None
self.gen_subseeds(last_idx,last_sid=sid)
return self.existing_subseed_by_seed_id(sid)
def gen_subseeds(self,last_idx=g.subseeds,last_sid=None):
first_idx = len(self.subseeds['long']) + 1
if first_idx > last_idx:
return None
if last_sid != None:
last_sid = SeedID(sid=last_sid)
def add_subseed(idx,length):
for nonce in range(SubSeed.max_nonce): # use nonce to handle Seed ID collisions
sid = make_chksum_8(SubSeedBase.make_subseed_bin(self,idx,nonce,length))
if not (sid in self.subseeds['long'] or sid in self.subseeds['short']):
self.subseeds[length][sid] = (idx,nonce)
return last_sid == sid
elif g.debug_subseed: # should get ≈450 collisions for first 1,000,000 subseeds
k = ('long','short')[sid in self.subseeds['short']]
m1 = 'add_subseed(idx={},{}):'.format(idx,length)
m2 = 'collision with ID {} (idx={},{}),'.format(sid,self.subseeds[k][sid][0],k)
msg('{:30} {:46} incrementing nonce to {}'.format(m1,m2,nonce+1))
else: # must exit here, as this could leave self.subseeds in inconsistent state
raise SubSeedNonceRangeExceeded('add_subseed(): nonce range exceeded')
for idx in SubSeedIdxRange(first_idx,last_idx).iterate():
if add_subseed(idx,'long') + add_subseed(idx,'short'):
break
def fmt_subseeds(self,first_idx=1,last_idx=g.subseeds):
r = SubSeedIdxRange(first_idx,last_idx)
if len(self.subseeds['long']) < last_idx:
self.gen_subseeds(last_idx)
fs1 = '{:>18} {:>18}\n'
fs2 = '{i:>7}L: {:8} {i:>7}S: {:8}\n'
hdr = '{:>16} {} ({} bits)\n\n'.format('Parent Seed:',self.sid.hl(),self.length)
hdr += fs1.format('Long Subseeds','Short Subseeds')
hdr += fs1.format('-------------','--------------')
sl = tuple(self.subseeds['long'])
ss = tuple(self.subseeds['short'])
body = (fs2.format(sl[n-1],ss[n-1],i=n) for n in r.iterate())
return hdr + ''.join(body)
class SubSeedBase(MMGenObject):
max_nonce = 1000
@staticmethod
def make_subseed_bin(parent,idx:int,nonce:int,length:str):
short = { 'short': True, 'long': False }[length]
# field maximums: idx: 4294967295, nonce: 65535, short (bool): 255
scramble_key = idx.to_bytes(4,'big',signed=False) + \
nonce.to_bytes(2,'big',signed=False) + \
short.to_bytes(1,'big',signed=False)
byte_len = 16 if short else parent.length // 8
return scramble_seed(parent.data,scramble_key,g.scramble_hash_rounds)[:byte_len]
class SubSeed(SeedBase,SubSeedBase):
idx = MMGenImmutableAttr('idx',int,typeconv=False)
nonce = MMGenImmutableAttr('nonce',int,typeconv=False)
ss_idx = MMGenImmutableAttr('ss_idx',str,typeconv=False)
def __init__(self,parent,idx,nonce,length):
self.idx = idx
self.nonce = nonce
self.ss_idx = str(idx) + { 'long': 'L', 'short': 'S' }[length]
SeedBase.__init__(self,seed_bin=SubSeedBase.make_subseed_bin(parent,idx,nonce,length))
class SeedSource(MMGenObject): class SeedSource(MMGenObject):
desc = g.proj_name + ' seed source' desc = g.proj_name + ' seed source'

View file

@ -671,7 +671,29 @@ class MMGenToolCmdFileUtil(MMGenToolCmdBase):
return True return True
class MMGenToolCmdWallet(MMGenToolCmdBase): class MMGenToolCmdWallet(MMGenToolCmdBase):
"key or address generation from an MMGen wallet" "key, address or subseed generation from an MMGen wallet"
def get_subseed(self,subseed_idx:str,wallet=''):
"get the Seed ID of a single subseed by Subseed Index for default or specified wallet"
opt.quiet = True
sf = get_seed_file([wallet] if wallet else [],1)
from mmgen.seed import SeedSource
return SeedSource(sf).seed.subseed(subseed_idx).sid
def get_subseed_by_seed_id(self,seed_id:str,wallet='',last_idx=g.subseeds):
"get the Subseed Index of a single subseed by Seed ID for default or specified wallet"
opt.quiet = True
sf = get_seed_file([wallet] if wallet else [],1)
from mmgen.seed import SeedSource
ret = SeedSource(sf).seed.subseed_by_seed_id(seed_id,last_idx)
return ret.ss_idx if ret else None
def list_subseeds(self,subseed_idx_range:str,wallet=''):
"list a range of subseed Seed IDs for default or specified wallet"
opt.quiet = True
sf = get_seed_file([wallet] if wallet else [],1)
from mmgen.seed import SeedSource
return SeedSource(sf).seed.fmt_subseeds(*SubSeedIdxRange(subseed_idx_range))
def gen_key(self,mmgen_addr:str,wallet=''): def gen_key(self,mmgen_addr:str,wallet=''):
"generate a single MMGen WIF key from default or specified wallet" "generate a single MMGen WIF key from default or specified wallet"

View file

@ -160,6 +160,7 @@ setup(
'cmds/mmgen-addrimport', 'cmds/mmgen-addrimport',
'cmds/mmgen-passchg', 'cmds/mmgen-passchg',
'cmds/mmgen-regtest', 'cmds/mmgen-regtest',
'cmds/mmgen-subwalletgen',
'cmds/mmgen-walletchk', 'cmds/mmgen-walletchk',
'cmds/mmgen-walletconv', 'cmds/mmgen-walletconv',
'cmds/mmgen-walletgen', 'cmds/mmgen-walletgen',

View file

@ -119,6 +119,15 @@ tests = OrderedDict([
('101,1,3,5,2-7,99',[1,2,3,4,5,6,7,99,101]), ('101,1,3,5,2-7,99',[1,2,3,4,5,6,7,99,101]),
({'idx_list':AddrIdxList('1-5')},[1,2,3,4,5]) ({'idx_list':AddrIdxList('1-5')},[1,2,3,4,5])
)}), )}),
('SubSeedIdxRange', {
'bad': (33,'x','-11','66,3','0','3-2','8000000','100000000',(1,2,3)),
'good': (
('3',(3,3)),
((3,5),(3,5)),
('1-2',(1,2)),
(str(g.subseeds),(g.subseeds,g.subseeds)),
(str(SubSeedIdxRange.max_idx),(SubSeedIdxRange.max_idx,SubSeedIdxRange.max_idx)),
)}),
('BTCAmt', { ('BTCAmt', {
'bad': ('-3.2','0.123456789',123,'123L','22000000',20999999.12345678), 'bad': ('-3.2','0.123456789',123,'123L','22000000',20999999.12345678),
'good': (('20999999.12345678',Decimal('20999999.12345678')),) 'good': (('20999999.12345678',Decimal('20999999.12345678')),)
@ -147,6 +156,10 @@ tests = OrderedDict([
'я',r32,'abc'), 'я',r32,'abc'),
'good': (({'sid':'F00BAA12'},'F00BAA12'),(Seed(r16),Seed(r16).sid)) 'good': (({'sid':'F00BAA12'},'F00BAA12'),(Seed(r16),Seed(r16).sid))
}), }),
('SubSeedIdx', {
'bad': (33,'x','я','1x',200,'1ss','L','s','200LS','30ll','s100',str(SubSeedIdxRange.max_idx+1),'0'),
'good': (('1','1L'),('1s','1S'),'20S','30L',('300l','300L'),('200','200L'),str(SubSeedIdxRange.max_idx)+'S')
}),
('MMGenID', { ('MMGenID', {
'bad': ('x',1,'f00f00f','a:b','x:L:3','F00BAA12:0','F00BAA12:Z:99'), 'bad': ('x',1,'f00f00f','a:b','x:L:3','F00BAA12:0','F00BAA12:Z:99'),
'good': (('F00BAA12:99','F00BAA12:L:99'),'F00BAA12:L:99','F00BAA12:S:99') 'good': (('F00BAA12:99','F00BAA12:L:99'),'F00BAA12:L:99','F00BAA12:S:99')

View file

@ -66,6 +66,7 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
segwit_opts_ok = True segwit_opts_ok = True
cmd_group = ( cmd_group = (
('walletgen_dfl_wallet', (15,'wallet generation (default wallet)',[[[],15]])), ('walletgen_dfl_wallet', (15,'wallet generation (default wallet)',[[[],15]])),
('subwalletgen_dfl_wallet', (15,'subwallet generation (default wallet)',[[[pwfile],15]])),
('export_seed_dfl_wallet',(15,'seed export to mmseed format (default wallet)',[[[pwfile],15]])), ('export_seed_dfl_wallet',(15,'seed export to mmseed format (default wallet)',[[[pwfile],15]])),
('addrgen_dfl_wallet',(15,'address generation (default wallet)',[[[pwfile],15]])), ('addrgen_dfl_wallet',(15,'address generation (default wallet)',[[[pwfile],15]])),
('txcreate_dfl_wallet',(15,'transaction creation (default wallet)',[[['addrs'],15]])), ('txcreate_dfl_wallet',(15,'transaction creation (default wallet)',[[['addrs'],15]])),
@ -75,6 +76,8 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
('delete_dfl_wallet',(15,'delete default wallet',[[[pwfile],15]])), ('delete_dfl_wallet',(15,'delete default wallet',[[[pwfile],15]])),
('walletgen', (1,'wallet generation', [[['del_dw_run'],15]])), ('walletgen', (1,'wallet generation', [[['del_dw_run'],15]])),
('subwalletgen', (1,'subwallet generation', [[['mmdat'],1]])),
('subwalletgen_mnemonic',(1,'subwallet generation (to mnemonic format)',[[['mmdat'],1]])),
# ('walletchk', (1,'wallet check', [[['mmdat'],1]])), # ('walletchk', (1,'wallet check', [[['mmdat'],1]])),
('passchg', (5,'password, label and hash preset change',[[['mmdat',pwfile],1]])), ('passchg', (5,'password, label and hash preset change',[[['mmdat',pwfile],1]])),
('passchg_keeplabel',(5,'password, label and hash preset change (keep label)',[[['mmdat',pwfile],1]])), ('passchg_keeplabel',(5,'password, label and hash preset change (keep label)',[[['mmdat',pwfile],1]])),
@ -157,6 +160,9 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
def walletgen_dfl_wallet(self,seed_len=None): def walletgen_dfl_wallet(self,seed_len=None):
return self.walletgen(seed_len=seed_len,gen_dfl_wallet=True) return self.walletgen(seed_len=seed_len,gen_dfl_wallet=True)
def subwalletgen_dfl_wallet(self,pf):
return self.subwalletgen(wf='default')
def export_seed_dfl_wallet(self,pf,desc='seed data',out_fmt='seed'): def export_seed_dfl_wallet(self,pf,desc='seed data',out_fmt='seed'):
return self.export_seed(wf=None,desc=desc,out_fmt=out_fmt,pf=pf) return self.export_seed(wf=None,desc=desc,out_fmt=out_fmt,pf=pf)
@ -201,6 +207,29 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
t.written_to_file('MMGen wallet') t.written_to_file('MMGen wallet')
return t return t
def subwalletgen(self,wf):
args = [self.usr_rand_arg,'-p1','-d',self.tr.trash_dir,'-L','Label']
if wf != 'default': args += [wf]
t = self.spawn('mmgen-subwalletgen', args + ['10s'])
t.license()
t.passphrase('MMGen wallet',self.cfgs['1']['wpasswd'])
t.expect('Generating subseed 10S')
t.passphrase_new('new MMGen wallet','foo')
t.usr_rand(self.usr_rand_chars)
fn = t.written_to_file('MMGen wallet')
assert fn[-6:] == '.mmdat','incorrect file extension: {}'.format(fn[-6:])
return t
def subwalletgen_mnemonic(self,wf):
args = [self.usr_rand_arg,'-p1','-d',self.tr.trash_dir,'-o','words',wf,'3L']
t = self.spawn('mmgen-subwalletgen', args)
t.license()
t.passphrase('MMGen wallet',self.cfgs['1']['wpasswd'])
t.expect('Generating subseed 3L')
fn = t.written_to_file('Mnemonic data')
assert fn[-8:] == '.mmwords','incorrect file extension: {}'.format(fn[-8:])
return t
def passchg(self,wf,pf,label_action='cmdline'): def passchg(self,wf,pf,label_action='cmdline'):
silence() silence()
self.write_to_tmpfile(pwfile,get_data_from_file(pf)) self.write_to_tmpfile(pwfile,get_data_from_file(pf))

View file

@ -57,6 +57,10 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
}, },
} }
chk_data = { chk_data = {
'ref_subwallet_sid': {
'98831F3A:32L':'D66B4885',
'98831F3A:1S':'20D95B09',
},
'ref_addrfile_chksum': { 'ref_addrfile_chksum': {
'btc': ('6FEF 6FB9 7B13 5D91','424E 4326 CFFE 5F51'), 'btc': ('6FEF 6FB9 7B13 5D91','424E 4326 CFFE 5F51'),
'ltc': ('AD52 C3FE 8924 AAF0','4EBE 2E85 E969 1B30'), 'ltc': ('AD52 C3FE 8924 AAF0','4EBE 2E85 E969 1B30'),
@ -76,6 +80,8 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
'ref_passwdfile_chksum': 'A983 DAB9 5514 27FB', 'ref_passwdfile_chksum': 'A983 DAB9 5514 27FB',
} }
cmd_group = ( # TODO: move to tooltest2 cmd_group = ( # TODO: move to tooltest2
('ref_words_to_subwallet_chk1','subwallet generation from reference words file (long subseed)'),
('ref_words_to_subwallet_chk2','subwallet generation from reference words file (short subseed)'),
('ref_addrfile_chk', 'saved reference address file'), ('ref_addrfile_chk', 'saved reference address file'),
('ref_segwitaddrfile_chk','saved reference address file (segwit)'), ('ref_segwitaddrfile_chk','saved reference address file (segwit)'),
('ref_bech32addrfile_chk','saved reference address file (bech32)'), ('ref_bech32addrfile_chk','saved reference address file (bech32)'),
@ -102,6 +108,30 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
def ref_subdir(self): def ref_subdir(self):
return self._get_ref_subdir_by_coin(g.coin) return self._get_ref_subdir_by_coin(g.coin)
def ref_words_to_subwallet_chk1(self):
return self.ref_words_to_subwallet_chk('32L')
def ref_words_to_subwallet_chk2(self):
return self.ref_words_to_subwallet_chk('1S')
def ref_words_to_subwallet_chk(self,ss_idx):
wf = dfl_words_file
args = ['-d',self.tr.trash_dir,'-o','words',wf,ss_idx]
t = self.spawn('mmgen-subwalletgen',args,extra_desc='(generate subwallet)')
t.expect('Generating subseed {}'.format(ss_idx))
chk_sid = self.chk_data['ref_subwallet_sid']['98831F3A:{}'.format(ss_idx)]
fn = t.written_to_file('Mnemonic data')
assert chk_sid in fn,'incorrect filename: {} (does not contain {})'.format(fn,chk_sid)
ok()
t = self.spawn('mmgen-walletchk',[fn],extra_desc='(check subwallet)')
t.expect(r'Valid mnemonic data for Seed ID ([0-9A-F]*)\b',regex=True)
sid = t.p.match.group(1)
assert sid == chk_sid,'subseed ID {} does not match expected value {}'.format(sid,chk_sid)
t.read()
return t
def ref_addrfile_chk(self,ftype='addr',coin=None,subdir=None,pfx=None,mmtype=None,add_args=[]): def ref_addrfile_chk(self,ftype='addr',coin=None,subdir=None,pfx=None,mmtype=None,add_args=[]):
af_key = 'ref_{}file'.format(ftype) af_key = 'ref_{}file'.format(ftype)
af_fn = TestSuiteRef.sources[af_key].format(pfx or self.altcoin_pfx,'' if coin else self.tn_ext) af_fn = TestSuiteRef.sources[af_key].format(pfx or self.altcoin_pfx,'' if coin else self.tn_ext)

View file

@ -293,6 +293,20 @@ tests = {
'3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn' '3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn'
), ),
], ],
'get_subseed': [
( ['3s','wallet=test/ref/98831F3A.mmwords'], '4018EB17' ),
( ['200','wallet=test/ref/98831F3A.mmwords'], '2B05AE73' ),
],
'get_subseed_by_seed_id': [
( ['4018EB17','wallet=test/ref/98831F3A.mmwords'], '3S' ),
( ['2B05AE73','wallet=test/ref/98831F3A.mmwords'], None ),
( ['2B05AE73','wallet=test/ref/98831F3A.mmwords','last_idx=200'], '200L' ),
],
'list_subseeds': [
( ['1-5','wallet=test/ref/98831F3A.mmwords'],
(md5_hash_strip,'996c047e8543d5dde6f82efc3214a6a1')
),
],
}, },
'Coin': { 'Coin': {
'addr2pubhash': { 'addr2pubhash': {

View file

@ -157,6 +157,188 @@ class UnitTests(object):
return True return True
def subseed(self,name):
from mmgen.seed import Seed
from mmgen.obj import SubSeedIdxRange
def basic_ops():
msg_r('Testing basic ops...')
for a,b,c,d,e,f,h in (
(8,'4710FBF0','0C1B0615','803B165C','2669AC64',256,'10L'),
(6,'9D07ABBD','EBA9C33F','20787E6A','192E2AA2',192,'10L'),
(4,'43670520','04A4CCB3','B5F21D7B','C1934CFF',128,'10L'),
):
seed_bin = bytes.fromhex('deadbeef' * a)
seed = Seed(seed_bin)
assert seed.sid == b, seed.sid
subseed = seed.subseed('2s')
assert subseed.sid == c, subseed.sid
subseed = seed.subseed('3')
assert subseed.sid == d, subseed.sid
subseed = seed.subseed_by_seed_id(e)
assert subseed.length == f, subseed.length
assert subseed.sid == e, subseed.sid
assert subseed.idx == 10, subseed.idx
assert subseed.ss_idx == h, subseed.ss_idx
seed2 = Seed(seed_bin)
s2s = seed2.subseeds['short']
s2l = seed2.subseeds['long']
seed2.gen_subseeds(1)
assert len(s2s) == 1, len(s2s)
seed2.gen_subseeds(1) # do nothing
seed2.gen_subseeds(2) # append one item
seed2.gen_subseeds(5)
assert len(s2s) == 5, len(s2s)
seed2.gen_subseeds(3) # do nothing
assert len(s2l) == 5, len(s2l)
seed2.gen_subseeds(10)
assert len(s2s) == 10, len(s2s)
assert seed.pformat() == seed2.pformat()
s = seed.fmt_subseeds()
s_lines = s.strip().split('\n')
assert len(s_lines) == g.subseeds + 4, s
a = seed.subseed('2L').sid
b = [e for e in s_lines if ' 2L:' in e][0].strip().split()[1]
assert a == b, b
c = seed.subseed('2').sid
assert c == a, c
a = seed.subseed('5S').sid
b = [e for e in s_lines if ' 5S:' in e][0].strip().split()[3]
assert a == b, b
s = seed.fmt_subseeds(g.subseeds+1,g.subseeds+2)
s_lines = s.strip().split('\n')
assert len(s_lines) == 6, s
ss_idx = str(g.subseeds+2) + 'S'
a = seed.subseed(ss_idx).sid
b = [e for e in s_lines if ' {}:'.format(ss_idx) in e][0].strip().split()[3]
assert a == b, b
s = seed.fmt_subseeds(1,2)
s_lines = s.strip().split('\n')
assert len(s_lines) == 6, s
msg('OK')
def defaults_and_limits():
msg_r('Testing defaults and limits...')
seed_bin = bytes.fromhex('deadbeef' * 8)
seed = Seed(seed_bin)
seed.gen_subseeds()
ss = seed.subseeds
assert len(ss['short']) == g.subseeds, ss['short']
assert len(ss['long']) == g.subseeds, ss['long']
seed = Seed(seed_bin)
seed.subseed_by_seed_id('EEEEEEEE')
ss = seed.subseeds
assert len(ss['short']) == g.subseeds, ss['short']
assert len(ss['long']) == g.subseeds, ss['long']
seed = Seed(seed_bin)
subseed = seed.subseed_by_seed_id('803B165C')
assert subseed.sid == '803B165C', subseed.sid
assert subseed.idx == 3, subseed.idx
seed = Seed(seed_bin)
subseed = seed.subseed_by_seed_id('803B165C',last_idx=1)
assert subseed == None, subseed
r = SubSeedIdxRange('1-5')
r2 = SubSeedIdxRange(1,5)
assert r2 == r, r2
assert r == (r.first,r.last), r
assert r.first == 1, r.first
assert r.last == 5, r.last
assert r.items == [1,2,3,4,5], r.items
assert list(r.iterate()) == r.items, list(r.iterate())
r = SubSeedIdxRange('22')
r2 = SubSeedIdxRange(22,22)
assert r2 == r, r2
assert r == (r.first,r.last), r
assert r.first == 22, r.first
assert r.last == 22, r.last
assert r.items == [22], r
assert list(r.iterate()) == r.items, list(r.iterate())
r = SubSeedIdxRange('3-3')
assert r.items == [3], r.items
r = SubSeedIdxRange('{}-{}'.format(g.subseeds-1,g.subseeds))
assert r.items == [g.subseeds-1,g.subseeds], r.items
for n,e in enumerate(SubSeedIdxRange('1-5').iterate(),1):
assert n == e, e
assert n == 5, n
msg('OK')
def collisions():
msg_r('Testing Seed ID collisions ({} subseed pairs)...'.format(59344))
seed_bin = bytes.fromhex('deadbeef' * 8)
seed = Seed(seed_bin)
subseed = seed.subseed('29429s')
assert subseed.sid == 'AE4C5E39', subseed.sid
assert subseed.nonce == 1, subseed.nonce
subseed = seed.subseed('59344')
assert subseed.sid == 'FC4AD16F', subseed.sid
assert subseed.nonce == 1, subseed.nonce
subseed2 = seed.subseed_by_seed_id('FC4AD16F')
assert subseed.pformat() == subseed2.pformat()
msg('OK')
def count_collisions():
msg_r('Counting Seed ID collisions ({} subseed pairs)...'.format(SubSeedIdxRange.max_idx))
seed_bin = bytes.fromhex('12abcdef' * 8)
seed = Seed(seed_bin)
seed.gen_subseeds(SubSeedIdxRange.max_idx)
ss = seed.subseeds
for sid in ss['long']:
# msg(sid)
assert sid not in ss['short']
collisions = 0
for k in ('short','long'):
for sid in ss[k]:
collisions += ss[k][sid][1]
assert collisions == 470, collisions
msg_r('({} collisions) '.format(collisions))
msg('OK')
basic_ops()
defaults_and_limits()
collisions()
count_collisions()
return True
def exit_msg(): def exit_msg():
t = int(time.time()) - start_time t = int(time.time()) - start_time
gmsg('All requested tests finished OK, elapsed time: {:02}:{:02}'.format(t//60,t%60)) gmsg('All requested tests finished OK, elapsed time: {:02}:{:02}'.format(t//60,t%60))