From 7538a9460e897b9b23d8ac58853c33713334043f Mon Sep 17 00:00:00 2001 From: MMGen Date: Sun, 12 May 2019 12:29:35 +0000 Subject: [PATCH] Subwallets, Part 1: basic framework and subwallet generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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' --- cmds/mmgen-subwalletgen | 24 +++++ data_files/mmgen.cfg | 3 + mmgen/common.py | 37 ++++++++ mmgen/exception.py | 1 + mmgen/globalvars.py | 7 +- mmgen/main.py | 2 +- mmgen/main_wallet.py | 29 +++++- mmgen/obj.py | 32 ++++++- mmgen/seed.py | 110 ++++++++++++++++++++++- mmgen/tool.py | 24 ++++- setup.py | 1 + test/objtest.py | 13 +++ test/test_py_d/ts_main.py | 29 ++++++ test/test_py_d/ts_ref.py | 30 +++++++ test/tooltest2.py | 14 +++ test/unit_tests.py | 182 ++++++++++++++++++++++++++++++++++++++ 16 files changed, 527 insertions(+), 11 deletions(-) create mode 100755 cmds/mmgen-subwalletgen diff --git a/cmds/mmgen-subwalletgen b/cmds/mmgen-subwalletgen new file mode 100755 index 00000000..86765cf7 --- /dev/null +++ b/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 +# +# 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 . + +""" +mmgen-subwalletgen: Generate a subwallet from an MMGen deterministic wallet +""" + +from mmgen.main import launch +launch("subwalletgen") diff --git a/data_files/mmgen.cfg b/data_files/mmgen.cfg index 6a6a5819..e248bad8 100644 --- a/data_files/mmgen.cfg +++ b/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: diff --git a/mmgen/common.py b/mmgen/common.py index 2d96939a..08f42ec0 100755 --- a/mmgen/common.py +++ b/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: diff --git a/mmgen/exception.py b/mmgen/exception.py index a07f4dae..207daed8 100755 --- a/mmgen/exception.py +++ b/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 diff --git a/mmgen/globalvars.py b/mmgen/globalvars.py index 7ee56480..cdd4c05b 100755 --- a/mmgen/globalvars.py +++ b/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 diff --git a/mmgen/main.py b/mmgen/main.py index 0606546f..90707745 100755 --- a/mmgen/main.py +++ b/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' diff --git a/mmgen/main_wallet.py b/mmgen/main_wallet.py index 5877df65..c99173ed 100755 --- a/mmgen/main_wallet.py +++ b/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] ' + 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())) diff --git a/mmgen/obj.py b/mmgen/obj.py index b14cf97a..c2a359a6 100755 --- a/mmgen/obj.py +++ b/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 diff --git a/mmgen/seed.py b/mmgen/seed.py index 7a980145..caf491a4 100755 --- a/mmgen/seed.py +++ b/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' diff --git a/mmgen/tool.py b/mmgen/tool.py index 42eee1dc..9ce1dd7d 100755 --- a/mmgen/tool.py +++ b/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" diff --git a/setup.py b/setup.py index 61dccb3c..ca6b8d02 100755 --- a/setup.py +++ b/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', diff --git a/test/objtest.py b/test/objtest.py index f2f9594b..a9a77ec9 100755 --- a/test/objtest.py +++ b/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') diff --git a/test/test_py_d/ts_main.py b/test/test_py_d/ts_main.py index f7bc9a0d..f8042ed7 100755 --- a/test/test_py_d/ts_main.py +++ b/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)) diff --git a/test/test_py_d/ts_ref.py b/test/test_py_d/ts_ref.py index e8bc1c35..79d9fd63 100755 --- a/test/test_py_d/ts_ref.py +++ b/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) diff --git a/test/tooltest2.py b/test/tooltest2.py index 15eeedd4..0d4c1b8a 100755 --- a/test/tooltest2.py +++ b/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': { diff --git a/test/unit_tests.py b/test/unit_tests.py index a9a5d149..16617435 100755 --- a/test/unit_tests.py +++ b/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))