Browse Source

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'
MMGen 5 years ago
parent
commit
7538a9460e

+ 24 - 0
cmds/mmgen-subwalletgen

@@ -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")

+ 3 - 0
data_files/mmgen.cfg

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

+ 37 - 0
mmgen/common.py

@@ -28,6 +28,7 @@ from mmgen.opts import opt
 from mmgen.util import *
 
 def help_notes(k):
+	from mmgen.obj import SubSeedIdxRange
 	from mmgen.seed import SeedSource
 	from mmgen.tx import MMGenTX
 	def fee_spec_letters(use_quotes=False):
@@ -40,6 +41,42 @@ def help_notes(k):
 	return {
 		'rel_fee_desc': MMGenTX().rel_fee_desc,
 		'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': """
 PASSPHRASE NOTE:
 

+ 1 - 0
mmgen/exception.py

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

+ 5 - 2
mmgen/globalvars.py

@@ -73,6 +73,7 @@ class g(object):
 	debug_opts           = False
 	debug_rpc            = False
 	debug_addrlist       = False
+	debug_subseed        = False
 	quiet                = False
 	no_license           = False
 	force_256_color      = False
@@ -128,7 +129,7 @@ class g(object):
 						'quiet','tx_confs','tx_fee_adj','key_generator' )
 
 	# 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
 	common_opts = (
@@ -155,7 +156,7 @@ class g(object):
 	cfg_file_opts = (
 		'color','debug','hash_preset','http_timeout','no_license','rpc_host','rpc_port',
 		'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',
 		'eth_mainnet_chain_name','eth_testnet_chain_name',
 		'max_tx_file_size','max_input_size'
@@ -175,6 +176,7 @@ class g(object):
 		'MMGEN_DEBUG_RPC',
 		'MMGEN_DEBUG_ADDRLIST',
 		'MMGEN_DEBUG_UTF8',
+		'MMGEN_DEBUG_SUBSEED',
 		'MMGEN_QUIET',
 		'MMGEN_FORCE_256_COLOR',
 		'MMGEN_MIN_URANDCHARS',
@@ -201,6 +203,7 @@ class g(object):
 
 	seed_lens = 128,192,256
 	scramble_hash_rounds = 10
+	subseeds = 100
 
 	mmenc_ext      = 'mmenc'
 	salt_len       = 16

+ 1 - 1
mmgen/main.py

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

+ 25 - 4
mmgen/main_wallet.py

@@ -31,12 +31,14 @@ nargs = 1
 iaction = 'convert'
 oaction = 'convert'
 do_bw_note = True
+do_sw_note = False
 
 invoked_as = {
 	'mmgen-walletgen':    'gen',
 	'mmgen-walletconv':   'conv',
 	'mmgen-walletchk':    'chk',
 	'mmgen-passchg':      'passchg',
+	'mmgen-subwalletgen': 'subgen',
 }[g.prog_name]
 
 # full: defhHiJkKlLmoOpPqrSvz-
@@ -58,6 +60,11 @@ elif invoked_as == 'passchg':
 	opt_filter = 'efhdiHkKOlLmpPqrSvz-'
 	iaction = 'input'
 	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 = {
 	'text': {
@@ -96,7 +103,7 @@ opts_data = {
 """,
 	'notes': """
 
-{n_pw}{n_bw}
+{n_sw}{n_pw}{n_bw}
 
 FMT CODES:
 
@@ -111,6 +118,7 @@ FMT CODES:
 		),
 		'notes': lambda s: s.format(
 			f='\n  '.join(SeedSource.format_fmt_codes().splitlines()),
+			n_sw=('',help_notes('subwallet')+'\n\n')[do_sw_note],
 			n_pw=help_notes('passwd'),
 			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:
 	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)
 
 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')
 	m2 = yellow(' (default wallet)') if sf and os.path.dirname(sf) == g.data_dir else ''
 	msg(m1+m2)
@@ -139,10 +151,19 @@ if invoked_as == 'chk':
 	# TODO: display creation date
 	sys.exit(0)
 
-if invoked_as in ('conv','passchg'):
+if invoked_as in ('conv','passchg','subgen'):
 	gmsg('Processing output wallet')
 
-ss_out = SeedSource(ss=ss_in,passchg=invoked_as=='passchg')
+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')
 
 if invoked_as == 'gen':
 	qmsg("This wallet's Seed ID: {}".format(ss_out.seed.sid.hl()))

+ 30 - 2
mmgen/obj.py

@@ -361,6 +361,10 @@ class MMGenRange(tuple,InitErrors,MMGenObject):
 	def items(self):
 		return list(self.iterate())
 
+class SubSeedIdxRange(MMGenRange):
+	min_idx = 1
+	max_idx = 1000000
+
 class UnknownCoinAmt(Decimal): pass
 
 class BTCAmt(Decimal,Hilite,InitErrors):
@@ -534,8 +538,8 @@ class SeedID(str,Hilite,InitErrors):
 		cls.arg_chk(cls,on_fail)
 		try:
 			if seed:
-				from mmgen.seed import Seed
-				assert type(seed) == Seed,'not a Seed instance'
+				from mmgen.seed import Seed,SubSeed
+				assert type(seed) in (Seed,SubSeed),'not a Seed or SubSeed instance'
 				from mmgen.util import make_chksum_8
 				return str.__new__(cls,make_chksum_8(seed.data))
 			elif sid:
@@ -547,6 +551,30 @@ class SeedID(str,Hilite,InitErrors):
 			m = "{!r}: value cannot be converted to SeedID ({})"
 			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):
 	color = 'orange'
 	width = 0

+ 109 - 1
mmgen/seed.py

@@ -45,7 +45,7 @@ def is_mnemonic(s):
 		opt.quiet = oq_save
 	return ret
 
-class Seed(MMGenObject):
+class SeedBase(MMGenObject):
 
 	data    = MMGenImmutableAttr('data',bytes,typeconv=False)
 	hexdata = MMGenImmutableAttr('hexdata',str,typeconv=False)
@@ -64,6 +64,114 @@ class Seed(MMGenObject):
 		self.sid       = SeedID(seed=self)
 		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):
 
 	desc = g.proj_name + ' seed source'

+ 23 - 1
mmgen/tool.py

@@ -671,7 +671,29 @@ class MMGenToolCmdFileUtil(MMGenToolCmdBase):
 		return True
 
 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=''):
 		"generate a single MMGen WIF key from default or specified wallet"

+ 1 - 0
setup.py

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

+ 13 - 0
test/objtest.py

@@ -119,6 +119,15 @@ tests = OrderedDict([
 			('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])
 		)}),
+	('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', {
 		'bad':  ('-3.2','0.123456789',123,'123L','22000000',20999999.12345678),
 		'good': (('20999999.12345678',Decimal('20999999.12345678')),)
@@ -147,6 +156,10 @@ tests = OrderedDict([
 			'я',r32,'abc'),
 		'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', {
 		'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')

+ 29 - 0
test/test_py_d/ts_main.py

@@ -66,6 +66,7 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
 	segwit_opts_ok = True
 	cmd_group = (
 		('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]])),
 		('addrgen_dfl_wallet',(15,'address generation (default wallet)',[[[pwfile],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]])),
 
 		('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]])),
 		('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]])),
@@ -157,6 +160,9 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
 	def walletgen_dfl_wallet(self,seed_len=None):
 		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'):
 		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')
 		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'):
 		silence()
 		self.write_to_tmpfile(pwfile,get_data_from_file(pf))

+ 30 - 0
test/test_py_d/ts_ref.py

@@ -57,6 +57,10 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		},
 	}
 	chk_data = {
+		'ref_subwallet_sid': {
+			'98831F3A:32L':'D66B4885',
+			'98831F3A:1S':'20D95B09',
+		},
 		'ref_addrfile_chksum': {
 			'btc': ('6FEF 6FB9 7B13 5D91','424E 4326 CFFE 5F51'),
 			'ltc': ('AD52 C3FE 8924 AAF0','4EBE 2E85 E969 1B30'),
@@ -76,6 +80,8 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		'ref_passwdfile_chksum':   'A983 DAB9 5514 27FB',
 	}
 	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_segwitaddrfile_chk','saved reference address file (segwit)'),
 		('ref_bech32addrfile_chk','saved reference address file (bech32)'),
@@ -102,6 +108,30 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 	def ref_subdir(self):
 		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=[]):
 		af_key = 'ref_{}file'.format(ftype)
 		af_fn = TestSuiteRef.sources[af_key].format(pfx or self.altcoin_pfx,'' if coin else self.tn_ext)

+ 14 - 0
test/tooltest2.py

@@ -293,6 +293,20 @@ tests = {
 				'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': {
 		'addr2pubhash': {

+ 182 - 0
test/unit_tests.py

@@ -157,6 +157,188 @@ class UnitTests(object):
 
 		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():
 	t = int(time.time()) - start_time
 	gmsg('All requested tests finished OK, elapsed time: {:02}:{:02}'.format(t//60,t%60))