Browse Source

N-of-N (XOR) seed splitting: user-level support

This patch introduces the commands `mmgen-seedsplit` and `mmgen-seedjoin`.
The first creates shares one at a time, while the second joins them to
recover the original seed.  By default, the default wallet is operated upon.

Shares are ordinary MMGen wallets and as such may be saved in any MMGen wallet
format, with one minor limitation: only one share in a given split may be in
hidden incognito format, and it must be the master share in the case of a
master share split.

For usage information and examples, see:

    mmgen-seedsplit --help
    mmgen-seedjoin --help

Relevant tests:

    test/test.py -e seedsplit
    ls -lrt test/tmp23/* # list the created files

    test/objtest.py SeedSplitSpecifier

Related commits:

    7311f474 - seed splitting: seed-level infrastructure
    237567bc - master shares
MMGen 5 years ago
parent
commit
c7ca0c3d62

+ 24 - 0
cmds/mmgen-seedjoin

@@ -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-seedjoin: Recreate an MMGen deterministic wallet from its component shares
+"""
+
+from mmgen.main import launch
+launch("seedjoin")

+ 24 - 0
cmds/mmgen-seedsplit

@@ -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-seedsplit: Generate a seed split from an MMGen deterministic wallet
+"""
+
+from mmgen.main import launch
+launch("seedsplit")

+ 72 - 1
mmgen/common.py

@@ -28,7 +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.obj import SubSeedIdxRange,SeedShareIdx,SeedShareCount,MasterShareIdx
 	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):
@@ -41,6 +41,77 @@ 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(),
+		'seedsplit': """
+COMMAND NOTES:
+
+This command generates shares one at a time.  Shares may be output to any
+MMGen wallet format, with one limitation: only one share in a given split may
+be in hidden incognito format, and it must be the master share in the case of
+a master-share split.
+
+If the command's optional first argument is omitted, the default wallet is
+used for the split.
+
+The last argument is a seed split specifier consisting of an optional split
+ID, a share index, and a share count, all separated by colons.  The split ID
+must be a valid UTF-8 string.  If omitted, the ID 'default' is used.  The
+share index (the index of the share being generated) must be in the range
+{sia}-{sib} and the share count (the total number of shares in the split)
+in the range {sca}-{scb}.
+
+Master Shares
+
+Each seed has a total of {msb} master shares, which can be used as the first
+shares in multiple splits if desired.  To generate a master share, use the
+--master-share (-M) option with an index in the range {msa}-{msb} and omit
+the last argument.
+
+When creating and joining a split using a master share, ensure that the same
+master share index is used in all split and join commands.
+
+EXAMPLES:
+
+  Create a 3-way default split of your default wallet, outputting all shares
+  to default wallet format.  Rejoin the split:
+
+    $ mmgen-seedsplit 1:3 # Step A
+    $ mmgen-seedsplit 2:3 # Step B
+    $ mmgen-seedsplit 3:3 # Step C
+    $ mmgen-seedjoin <output_of_step_A> <output_of_step_B> <output_of_step_C>
+
+  Create a 2-way split of your default wallet with ID string 'alice',
+  outputting shares to MMGen native mnemonic format.  Rejoin the split:
+
+    $ mmgen-seedsplit -o words alice:1:2 # Step D
+    $ mmgen-seedsplit -o words alice:2:2 # Step E
+    $ mmgen-seedjoin <output_of_step_D> <output_of_step_E>
+
+  Create a 2-way split of your default wallet with ID string 'bob' using
+  master share #7, outputting share #1 (the master share) to default wallet
+  format and share #2 to BIP39 format.  Rejoin the split:
+
+    $ mmgen-seedsplit -M7                   # Step X
+    $ mmgen-seedsplit -M7 -o bip39 bob:2:2  # Step Y
+    $ mmgen-seedjoin -M7 --id-str=bob <output_of_step_X> <output_of_step_Y>
+
+  Create a 2-way split of your default wallet with ID string 'alice' using
+  master share #7.  Rejoin the split using master share #7 generated in the
+  previous example:
+
+    $ mmgen-seedsplit -M7 -o bip39 alice:2:2 # Step Z
+    $ mmgen-seedjoin -M7 --id-str=alice <output_of_step_X> <output_of_step_Z>
+
+  Create a 2-way default split of your default wallet with an incognito-format
+  master share hidden in file 'my.hincog' at offset 1325.  Rejoin the split:
+
+    $ mmgen-seedsplit -M4 -o hincog -J my.hincog,1325 1:2 # Step M (share A)
+    $ mmgen-seedsplit -M4 -o bip39 2:2                    # Step N (share B)
+    $ mmgen-seedjoin -M4 -H my.hincog,1325 <output_of_step_N>
+
+""".strip().format(
+	sia=SeedShareIdx.min_val,sib=SeedShareIdx.max_val,
+	sca=SeedShareCount.min_val,scb=SeedShareCount.max_val,
+	msa=MasterShareIdx.min_val,msb=MasterShareIdx.max_val),
 		'subwallet': """
 		'subwallet': """
 SUBWALLETS:
 SUBWALLETS:
 
 

+ 1 - 1
mmgen/main.py

@@ -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','subwalletgen'):
+	if mod in ('walletgen','walletchk','walletconv','passchg','subwalletgen','seedsplit'):
 		mod = 'wallet'
 		mod = 'wallet'
 	if mod == 'keygen': mod = 'addrgen'
 	if mod == 'keygen': mod = 'addrgen'
 
 

+ 146 - 0
mmgen/main_seedjoin.py

@@ -0,0 +1,146 @@
+#!/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/main_seedjoin: Regenerate an MMGen deterministic wallet from seed shares
+                     created by 'mmgen-seedsplit'
+"""
+
+from mmgen.common import *
+from mmgen.obj import MasterShareIdx,SeedSplitIDString,MMGenWalletLabel
+from mmgen.seed import Seed,SeedSource,SeedShareMasterJoining
+
+opts_data = {
+	'text': {
+		'desc': """Regenerate an MMGen deterministic wallet from seed shares
+                  created by 'mmgen-seedsplit'""",
+		'usage': '[options] share1 share2 [...shareN]',
+		'options': """
+-h, --help            Print this help message
+--, --longhelp        Print help message for long options (common options)
+-d, --outdir=      d  Output file to directory 'd' instead of working dir
+-e, --echo-passphrase Echo passphrases and other user input to screen
+-i, --id-str=      s  ID String of split (required for master share join only)
+-H, --hidden-incog-input-params=f,o  Read hidden incognito data from file
+                      'f' at offset 'o' (comma-separated).  NOTE: only the
+                      first share may be in hidden incognito format!
+-J, --hidden-incog-output-params=f,o  Write hidden incognito data to file
+                      'f' at offset 'o' (comma-separated). File 'f' will be
+                      created if necessary and filled with random data.
+-o, --out-fmt=     f  Output to wallet format 'f' (see FMT CODES below)
+-O, --old-incog-fmt   Specify old-format incognito input
+-L, --label=       l  Specify a label 'l' for output wallet
+-M, --master-share=i  Use a master share with index 'i' (min:{ms_min}, max:{ms_max})
+-p, --hash-preset= p  Use the scrypt hash parameters defined by preset 'p'
+                      for password hashing (default: '{g.hash_preset}')
+-z, --show-hash-presets Show information on available hash presets
+-P, --passwd-file= f  Get wallet passphrase from file 'f'
+-q, --quiet           Produce quieter output; suppress some warnings
+-r, --usr-randchars=n Get 'n' characters of additional randomness from user
+                      (min={g.min_urandchars}, max={g.max_urandchars}, default={g.usr_randchars})
+-S, --stdout          Write wallet data to stdout instead of file
+-v, --verbose         Produce more verbose output
+""",
+	'notes': """
+
+COMMAND NOTES:
+
+When joining with a master share, the master share must be listed first.
+The remaining shares may be listed in any order.
+
+The --id-str option is required only for master share joins.  For ordinary
+joins it will be ignored.
+
+For usage examples, see the help screen for the 'mmgen-seedsplit' command.
+
+{n_pw}
+
+FMT CODES:
+
+  {f}
+"""
+	},
+	'code': {
+		'options': lambda s: s.format(
+			ms_min=MasterShareIdx.min_val,
+			ms_max=MasterShareIdx.max_val,
+			g=g,
+		),
+		'notes': lambda s: s.format(
+			f='\n  '.join(SeedSource.format_fmt_codes().splitlines()),
+			n_pw=help_notes('passwd'),
+		)
+	}
+}
+
+def print_shares_info():
+	si,out = 0,'\nComputed shares:\n'
+	if opt.master_share:
+		fs = '{:3}: {}->{} ' + yellow('(master share #{}, split id ') + '{}' + yellow(', share count {})\n')
+		out += fs.format(
+				1,
+				shares[0].sid,
+				share1.sid,
+				master_idx,
+				id_str.hl(encl="''"),
+				len(shares) )
+		si = 1
+	for n,s in enumerate(shares[si:],si+1):
+		out += '{:3}: {}\n'.format(n,s.sid)
+	qmsg(out)
+
+cmd_args = opts.init(opts_data)
+
+if len(cmd_args) + bool(opt.hidden_incog_input_params) < 2:
+	opts.usage()
+
+if opt.label:
+	opt.label = MMGenWalletLabel(opt.label,msg="Error in option '--label'")
+
+if opt.master_share:
+	master_idx = MasterShareIdx(opt.master_share)
+	id_str = SeedSplitIDString(opt.id_str or 'default')
+
+if opt.id_str and not opt.master_share:
+	die(1,'--id-str option meaningless in context of non-master-share join')
+
+for arg in cmd_args:
+	check_wallet_extension(arg)
+	check_infile(arg)
+
+do_license_msg()
+
+qmsg('Input files:\n  {}\n'.format('\n  '.join(cmd_args)))
+
+shares = [SeedSource().seed] if opt.hidden_incog_input_params else []
+shares += [SeedSource(fn).seed for fn in cmd_args]
+
+if opt.master_share:
+	share1 = SeedShareMasterJoining(master_idx,shares[0],id_str,len(shares)).derived_seed
+else:
+	share1 = shares[0]
+
+print_shares_info()
+
+msg_r('Joining {n}-of-{n} XOR split...'.format(n=len(shares)))
+
+seed_out = Seed.join_shares([share1]+shares[1:])
+
+msg('OK\nJoined Seed ID: {}'.format(seed_out.sid.hl()))
+
+SeedSource(seed=seed_out).write_to_file()

+ 39 - 3
mmgen/main_wallet.py

@@ -24,7 +24,7 @@ import os
 from mmgen.common import *
 from mmgen.common import *
 from mmgen.seed import SeedSource,Wallet
 from mmgen.seed import SeedSource,Wallet
 from mmgen.filename import find_file_in_dir
 from mmgen.filename import find_file_in_dir
-from mmgen.obj import MMGenWalletLabel
+from mmgen.obj import MMGenWalletLabel,MasterShareIdx
 
 
 usage = '[opts] [infile]'
 usage = '[opts] [infile]'
 nargs = 1
 nargs = 1
@@ -32,6 +32,7 @@ iaction = 'convert'
 oaction = 'convert'
 oaction = 'convert'
 do_bw_note = True
 do_bw_note = True
 do_sw_note = False
 do_sw_note = False
+do_ss_note = False
 
 
 invoked_as = {
 invoked_as = {
 	'mmgen-walletgen':    'gen',
 	'mmgen-walletgen':    'gen',
@@ -39,6 +40,7 @@ invoked_as = {
 	'mmgen-walletchk':    'chk',
 	'mmgen-walletchk':    'chk',
 	'mmgen-passchg':      'passchg',
 	'mmgen-passchg':      'passchg',
 	'mmgen-subwalletgen': 'subgen',
 	'mmgen-subwalletgen': 'subgen',
+	'mmgen-seedsplit':    'seedsplit',
 }[g.prog_name]
 }[g.prog_name]
 
 
 dsw = 'the default or specified {pnm} wallet'
 dsw = 'the default or specified {pnm} wallet'
@@ -69,6 +71,13 @@ elif invoked_as == 'subgen':
 	iaction = 'input'
 	iaction = 'input'
 	oaction = 'output'
 	oaction = 'output'
 	do_sw_note = True
 	do_sw_note = True
+elif invoked_as == 'seedsplit':
+	desc = 'Generate a seed share from ' + dsw
+	opt_filter = 'dehHiJlLMIoOpPqrSvz-'
+	usage = '[opts] [infile] [<Split ID String>:]<index>:<share count>'
+	iaction = 'input'
+	oaction = 'output'
+	do_ss_note = True
 
 
 opts_data = {
 opts_data = {
 	'text': {
 	'text': {
@@ -95,6 +104,7 @@ opts_data = {
                       with non-standard (< {g.seed_len}-bit) seed lengths.
                       with non-standard (< {g.seed_len}-bit) seed lengths.
 -L, --label=       l  Specify a label 'l' for output wallet
 -L, --label=       l  Specify a label 'l' for output wallet
 -m, --keep-label      Reuse label of input wallet for output wallet
 -m, --keep-label      Reuse label of input wallet for output wallet
+-M, --master-share=i  Use a master share with index 'i' (min:{ms_min}, max:{ms_max})
 -p, --hash-preset= p  Use the scrypt hash parameters defined by preset 'p'
 -p, --hash-preset= p  Use the scrypt hash parameters defined by preset 'p'
                       for password hashing (default: '{g.hash_preset}')
                       for password hashing (default: '{g.hash_preset}')
 -z, --show-hash-presets Show information on available hash presets
 -z, --show-hash-presets Show information on available hash presets
@@ -107,7 +117,7 @@ opts_data = {
 """,
 """,
 	'notes': """
 	'notes': """
 
 
-{n_sw}{n_pw}{n_bw}
+{n_ss}{n_sw}{n_pw}{n_bw}
 
 
 FMT CODES:
 FMT CODES:
 
 
@@ -118,10 +128,13 @@ FMT CODES:
 		'options': lambda s: s.format(
 		'options': lambda s: s.format(
 			iaction=capfirst(iaction),
 			iaction=capfirst(iaction),
 			oaction=capfirst(oaction),
 			oaction=capfirst(oaction),
+			ms_min=MasterShareIdx.min_val,
+			ms_max=MasterShareIdx.max_val,
 			g=g,
 			g=g,
 		),
 		),
 		'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_ss=('',help_notes('seedsplit')+'\n\n')[do_ss_note],
 			n_sw=('',help_notes('subwallet')+'\n\n')[do_sw_note],
 			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]
@@ -137,6 +150,24 @@ if opt.label:
 if invoked_as == 'subgen':
 if invoked_as == 'subgen':
 	from mmgen.obj import SubSeedIdx
 	from mmgen.obj import SubSeedIdx
 	ss_idx = SubSeedIdx(cmd_args.pop())
 	ss_idx = SubSeedIdx(cmd_args.pop())
+elif invoked_as == 'seedsplit':
+	from mmgen.obj import SeedSplitSpecifier
+	master_share = MasterShareIdx(opt.master_share) if opt.master_share else None
+	if cmd_args:
+		sss = SeedSplitSpecifier(cmd_args.pop(),on_fail='silent')
+		if master_share:
+			if not sss:
+				sss = SeedSplitSpecifier('1:2')
+			elif sss.idx == 1:
+				m1 = 'Share index of 1 meaningless in master share context.'
+				m2 = 'To generate a master share, omit the seed split specifier.'
+				die(1,m1+'  '+m2)
+		elif not sss:
+			opts.usage()
+	elif master_share:
+		sss = SeedSplitSpecifier('1:2')
+	else:
+		opts.usage()
 
 
 if cmd_args:
 if cmd_args:
 	if invoked_as == 'gen' or len(cmd_args) > 1:
 	if invoked_as == 'gen' or len(cmd_args) > 1:
@@ -164,10 +195,15 @@ if invoked_as == 'chk':
 	sys.exit(0)
 	sys.exit(0)
 
 
 if invoked_as != 'gen':
 if invoked_as != 'gen':
-	gmsg('Processing output wallet')
+	gmsg_r('Processing output wallet' + ('\n',' ')[invoked_as == 'seedsplit'])
 
 
 if invoked_as == 'subgen':
 if invoked_as == 'subgen':
 	ss_out = SeedSource(seed_bin=ss_in.seed.subseed(ss_idx,print_msg=True).data)
 	ss_out = SeedSource(seed_bin=ss_in.seed.subseed(ss_idx,print_msg=True).data)
+elif invoked_as == 'seedsplit':
+	shares = ss_in.seed.split(sss.count,sss.id,master_share)
+	seed_out = shares.get_share_by_idx(sss.idx,base_seed=True)
+	msg(seed_out.get_desc(ui=True))
+	ss_out = SeedSource(seed=seed_out)
 else:
 else:
 	ss_out = SeedSource(ss=ss_in,passchg=invoked_as=='passchg')
 	ss_out = SeedSource(ss=ss_in,passchg=invoked_as=='passchg')
 
 

+ 18 - 0
mmgen/obj.py

@@ -39,6 +39,7 @@ def is_addrlist_id(s):   return AddrListID(s,on_fail='silent')
 def is_tw_label(s):      return TwLabel(s,on_fail='silent')
 def is_tw_label(s):      return TwLabel(s,on_fail='silent')
 def is_wif(s):           return WifKey(s,on_fail='silent')
 def is_wif(s):           return WifKey(s,on_fail='silent')
 def is_viewkey(s):       return ViewKey(s,on_fail='silent')
 def is_viewkey(s):       return ViewKey(s,on_fail='silent')
+def is_seed_split_specifier(s): return SeedSplitSpecifier(s,on_fail='silent')
 
 
 def truncate_str(s,width): # width = screen width
 def truncate_str(s,width): # width = screen width
 	wide_count = 0
 	wide_count = 0
@@ -822,6 +823,23 @@ class MMGenPWIDString(MMGenLabel):
 	forbidden = list(' :/\\')
 	forbidden = list(' :/\\')
 	trunc_ok = False
 	trunc_ok = False
 
 
+class SeedSplitSpecifier(str,Hilite,InitErrors,MMGenObject):
+	color = 'red'
+	def __new__(cls,s,on_fail='raise'):
+		if type(s) == cls: return s
+		cls.arg_chk(on_fail)
+		try:
+			arr = s.split(':')
+			assert len(arr) in (2,3), 'cannot be parsed'
+			a,b,c = arr if len(arr) == 3 else ['default'] + arr
+			me = str.__new__(cls,s)
+			me.id = SeedSplitIDString(a,on_fail=on_fail)
+			me.idx = SeedShareIdx(b,on_fail=on_fail)
+			me.count = SeedShareCount(c,on_fail=on_fail)
+			assert me.idx <= me.count, 'share index greater than share count'
+			return me
+		except Exception as e:
+			return cls.init_fail(e,s)
 
 
 class SeedSplitIDString(MMGenPWIDString):
 class SeedSplitIDString(MMGenPWIDString):
 	desc = 'seed split ID string'
 	desc = 'seed split ID string'

+ 46 - 3
mmgen/seed.py

@@ -366,7 +366,38 @@ class SeedShareList(SubSeedList):
 
 
 		return hdr + body1 + ''.join(body)
 		return hdr + body1 + ''.join(body)
 
 
-class SeedShare(SubSeed):
+class SeedShareBase(MMGenObject):
+
+	@property
+	def fn_stem(self):
+		pl = self.parent_list
+		msdata = '_with_master{}'.format(pl.master_share.idx) if pl.master_share else ''
+		return '{}-{}-{}of{}{}[{}]'.format(
+			pl.parent_seed.sid,
+			pl.id_str,
+			self.idx,
+			pl.count,
+			msdata,
+			self.sid)
+
+	@property
+	def desc(self):
+		return self.get_desc()
+
+	def get_desc(self,ui=False):
+		pl = self.parent_list
+		mss = ', with master share #{}'.format(pl.master_share.idx) if pl.master_share else ''
+		if ui:
+			m   = ( yellow("(share {} of {} of ")
+					+ pl.parent_seed.sid.hl()
+					+ yellow(', split id ')
+					+ pl.id_str.hl(encl="''")
+					+ yellow('{})') )
+		else:
+			m = "share {} of {} of " + pl.parent_seed.sid + ", split id '" + pl.id_str + "'{}"
+		return m.format(self.idx,pl.count,mss)
+
+class SeedShare(SeedShareBase,SubSeed):
 
 
 	@staticmethod
 	@staticmethod
 	def make_subseed_bin(parent_list,idx:int,nonce:int,length:str):
 	def make_subseed_bin(parent_list,idx:int,nonce:int,length:str):
@@ -380,7 +411,7 @@ class SeedShare(SubSeed):
 			scramble_key += b':master:' + parent_list.master_share.idx.to_bytes(2,'big')
 			scramble_key += b':master:' + parent_list.master_share.idx.to_bytes(2,'big')
 		return scramble_seed(seed.data,scramble_key)[:seed.byte_len]
 		return scramble_seed(seed.data,scramble_key)[:seed.byte_len]
 
 
-class SeedShareLast(SeedBase):
+class SeedShareLast(SeedShareBase,SeedBase):
 
 
 	idx = MMGenImmutableAttr('idx',SeedShareIdx)
 	idx = MMGenImmutableAttr('idx',SeedShareIdx)
 	nonce = 0
 	nonce = 0
@@ -401,7 +432,7 @@ class SeedShareLast(SeedBase):
 
 
 		return ret.to_bytes(seed.byte_len,'big')
 		return ret.to_bytes(seed.byte_len,'big')
 
 
-class SeedShareMaster(SeedBase):
+class SeedShareMaster(SeedBase,SeedShareBase):
 
 
 	idx = MMGenImmutableAttr('idx',MasterShareIdx)
 	idx = MMGenImmutableAttr('idx',MasterShareIdx)
 	nonce = MMGenImmutableAttr('nonce',int,typeconv=False)
 	nonce = MMGenImmutableAttr('nonce',int,typeconv=False)
@@ -414,6 +445,13 @@ class SeedShareMaster(SeedBase):
 
 
 		self.derived_seed = SeedBase(self.make_derived_seed_bin(parent_list.id_str,parent_list.count))
 		self.derived_seed = SeedBase(self.make_derived_seed_bin(parent_list.id_str,parent_list.count))
 
 
+	@property
+	def fn_stem(self):
+		return '{}-MASTER{}[{}]'.format(
+			self.parent_list.parent_seed.sid,
+			self.idx,
+			self.sid)
+
 	def make_base_seed_bin(self):
 	def make_base_seed_bin(self):
 		seed = self.parent_list.parent_seed
 		seed = self.parent_list.parent_seed
 		# field maximums: idx: 65535 (1024)
 		# field maximums: idx: 65535 (1024)
@@ -427,6 +465,11 @@ class SeedShareMaster(SeedBase):
 		scramble_key = id_str.encode() + b':' + count.to_bytes(2,'big')
 		scramble_key = id_str.encode() + b':' + count.to_bytes(2,'big')
 		return scramble_seed(self.data,scramble_key)[:self.byte_len]
 		return scramble_seed(self.data,scramble_key)[:self.byte_len]
 
 
+	def get_desc(self,ui=False):
+		psid = self.parent_list.parent_seed.sid
+		mss = 'master share #{} of '.format(self.idx)
+		return yellow('(' + mss) + psid.hl() + yellow(')') if ui else mss + psid
+
 class SeedShareMasterJoining(SeedShareMaster):
 class SeedShareMasterJoining(SeedShareMaster):
 
 
 	id_str = MMGenImmutableAttr('id_str',SeedSplitIDString)
 	id_str = MMGenImmutableAttr('id_str',SeedSplitIDString)

+ 11 - 0
mmgen/tool.py

@@ -726,6 +726,17 @@ class MMGenToolCmdWallet(MMGenToolCmdBase):
 		from mmgen.seed import SeedSource
 		from mmgen.seed import SeedSource
 		return SeedSource(sf).seed.subseeds.format(*SubSeedIdxRange(subseed_idx_range))
 		return SeedSource(sf).seed.subseeds.format(*SubSeedIdxRange(subseed_idx_range))
 
 
+	def list_shares(self,
+			share_count:int,
+			id_str='default',
+			master_share:"(min:1, max:{}, 0=no master share)".format(MasterShareIdx.max_val)=0,
+			wallet=''):
+		"list the Seed IDs of the shares resulting from a split of 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.split(share_count,id_str,master_share).format()
+
 	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"
 		return self.gen_addr(mmgen_addr,wallet,target='wif')
 		return self.gen_addr(mmgen_addr,wallet,target='wif')

+ 3 - 0
setup.py

@@ -142,6 +142,7 @@ setup(
 			'mmgen.main_autosign',
 			'mmgen.main_autosign',
 			'mmgen.main_passgen',
 			'mmgen.main_passgen',
 			'mmgen.main_regtest',
 			'mmgen.main_regtest',
+			'mmgen.main_seedjoin',
 			'mmgen.main_split',
 			'mmgen.main_split',
 			'mmgen.main_tool',
 			'mmgen.main_tool',
 			'mmgen.main_txbump',
 			'mmgen.main_txbump',
@@ -166,6 +167,8 @@ setup(
 			'cmds/mmgen-walletchk',
 			'cmds/mmgen-walletchk',
 			'cmds/mmgen-walletconv',
 			'cmds/mmgen-walletconv',
 			'cmds/mmgen-walletgen',
 			'cmds/mmgen-walletgen',
+			'cmds/mmgen-seedsplit',
+			'cmds/mmgen-seedjoin',
 			'cmds/mmgen-split',
 			'cmds/mmgen-split',
 			'cmds/mmgen-txcreate',
 			'cmds/mmgen-txcreate',
 			'cmds/mmgen-txbump',
 			'cmds/mmgen-txbump',

+ 8 - 0
test/objtest_py_d/ot_btc_mainnet.py

@@ -13,6 +13,8 @@ from mmgen.obj import *
 from mmgen.seed import *
 from mmgen.seed import *
 from .ot_common import *
 from .ot_common import *
 
 
+ssm = str(SeedShareCount.max_val)
+
 tests = OrderedDict([
 tests = OrderedDict([
 	('AddrIdx', {
 	('AddrIdx', {
 		'bad':  ('s',1.1,10000000,-1,0),
 		'bad':  ('s',1.1,10000000,-1,0),
@@ -202,4 +204,10 @@ tests = OrderedDict([
 			{'s':'P','ret':'P'},
 			{'s':'P','ret':'P'},
 		)
 		)
 	}),
 	}),
+	('SeedSplitSpecifier', {
+		'bad': ('M','αβ:2',1,'0:1','1:1','2:1','3:2','1:2000','abc:0:2'),
+		'good': (
+			('1:2','2:2','alice:2:2','αβ:2:2','1:'+ssm,ssm+':'+ssm)
+		)
+	}),
 ])
 ])

+ 2 - 0
test/test.py

@@ -322,6 +322,7 @@ cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses
 		},
 		},
 	},
 	},
 	'22': {},
 	'22': {},
+	'23': {},
 	'31': {},
 	'31': {},
 	'32': {},
 	'32': {},
 	'33': {},
 	'33': {},
@@ -448,6 +449,7 @@ class CmdGroupMgr(object):
 		'ref3':             ('TestSuiteRef3Seed',{'is3seed':True,'modname':'ref_3seed'}),
 		'ref3':             ('TestSuiteRef3Seed',{'is3seed':True,'modname':'ref_3seed'}),
 		'ref':              ('TestSuiteRef',{}),
 		'ref':              ('TestSuiteRef',{}),
 		'ref_altcoin':      ('TestSuiteRefAltcoin',{}),
 		'ref_altcoin':      ('TestSuiteRefAltcoin',{}),
+		'seedsplit':        ('TestSuiteSeedSplit',{}),
 		'tool':             ('TestSuiteTool',{'modname':'misc','full_data':True}),
 		'tool':             ('TestSuiteTool',{'modname':'misc','full_data':True}),
 		'input':            ('TestSuiteInput',{'modname':'misc','full_data':True}),
 		'input':            ('TestSuiteInput',{'modname':'misc','full_data':True}),
 		'output':           ('TestSuiteOutput',{'modname':'misc','full_data':True}),
 		'output':           ('TestSuiteOutput',{'modname':'misc','full_data':True}),

+ 248 - 0
test/test_py_d/ts_seedsplit.py

@@ -0,0 +1,248 @@
+#!/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/>.
+
+"""
+ts_seedsplit.py: Seed split/join tests for the test.py test suite
+"""
+
+from mmgen.globalvars import g
+from mmgen.opts import opt
+
+from test.test_py_d.ts_base import *
+
+ref_wf = 'test/ref/98831F3A.bip39'
+ref_sid = '98831F3A'
+wpasswd = 'abc'
+sh1_passwd = 'xyz'
+
+class TestSuiteSeedSplit(TestSuiteBase):
+	'splitting and joining seeds'
+	networks = ('btc',)
+	tmpdir_nums = [23]
+	cmd_group = (
+		('ss_walletgen',                'wallet generation'),
+		('ss_2way_A_dfl1',              '2-way seed split (share A)'),
+		('ss_2way_B_dfl1',              '2-way seed split (share B)'),
+		('ss_2way_join_dfl1',           '2-way seed join'),
+		('ss_2way_A_dfl2',              "2-way seed split 'default' (share A)"),
+		('ss_2way_B_dfl2',              "2-way seed split 'default' (share B)"),
+		('ss_2way_join_dfl2',           "2-way seed join 'default'"),
+		('ss_2way_A_alice',             "2-way seed split 'alice' (share A)"),
+		('ss_2way_B_alice',             "2-way seed split 'alice' (share B)"),
+		('ss_2way_join_alice',          "2-way seed join 'alice'"),
+		('ss_2way_join_alice_mix',      "2-way seed join 'alice' (out of order)"),
+		('ss_2way_A_dfl_master3',       '2-way seed split with master share #3 (share A)'),
+		('ss_2way_B_dfl_master3',       '2-way seed split with master share #3 (share B)'),
+		('ss_2way_join_dfl_master3',    '2-way seed join with master share #3'),
+		('ss_2way_A_dfl_usw',           '2-way seed split of user-specified wallet (share A)'),
+		('ss_2way_B_dfl_usw',           '2-way seed split of user-specified wallet (share B)'),
+		('ss_2way_join_dfl_usw',        '2-way seed join of user-specified wallet'),
+		('ss_3way_A_dfl',               '3-way seed split (share A)'),
+		('ss_3way_B_dfl',               '3-way seed split (share B)'),
+		('ss_3way_C_dfl',               '3-way seed split (share C)'),
+		('ss_3way_join_dfl',            '3-way seed join'),
+		('ss_3way_join_dfl_mix',        '3-way seed join (out of order)'),
+		('ss_3way_A_foobar_master7',    "3-way seed split 'φυβαρ' with master share #7 (share A)"),
+		('ss_3way_B_foobar_master7',    "3-way seed split 'φυβαρ' with master share #7 (share B)"),
+		('ss_3way_C_foobar_master7',    "3-way seed split 'φυβαρ' with master share #7 (share C)"),
+		('ss_3way_join_foobar_master7', "3-way seed join 'φυβαρ' with master share #7"),
+		('ss_3way_join_foobar_master7_mix',"3-way seed join 'φυβαρ' with master share #7 (out of order)"),
+
+		('ss_3way_join_dfl_bad_invocation',"bad invocation of 'mmgen-seedjoin' - --id-str with non-master join"),
+		('ss_bad_invocation1',          "bad invocation of 'mmgen-seedsplit' - no arguments"),
+		('ss_bad_invocation2',          "bad invocation of 'mmgen-seedsplit' - master share with split specifier"),
+		('ss_bad_invocation3',          "bad invocation of 'mmgen-seedsplit' - nonexistent file"),
+		('ss_bad_invocation4',          "bad invocation of 'mmgen-seedsplit' - invalid file extension"),
+		('ss_bad_invocation5',          "bad invocation of 'mmgen-seedjoin' - no arguments"),
+		('ss_bad_invocation6',          "bad invocation of 'mmgen-seedjoin' - one file argument"),
+		('ss_bad_invocation7',          "bad invocation of 'mmgen-seedjoin' - invalid file extension"),
+		('ss_bad_invocation8',          "bad invocation of 'mmgen-seedjoin' - nonexistent file"),
+		('ss_bad_invocation9',          "bad invocation of 'mmgen-seedsplit' - bad specifier"),
+		('ss_bad_invocation10',         "bad invocation of 'mmgen-seedsplit' - nonexistent file"),
+		('ss_bad_invocation11',         "bad invocation of 'mmgen-seedsplit' - invalid file extension"),
+	)
+
+	def get_tmp_subdir(self,subdir):
+		return os.path.join(self.tmpdir,subdir)
+
+	def ss_walletgen(self):
+		t = self.spawn('mmgen-walletgen', ['-r0','-p1'])
+		t.passphrase_new('new MMGen wallet',wpasswd)
+		t.label()
+		self.write_to_tmpfile('dfl.sid',t.expect_getend('Seed ID: '))
+		t.expect('move it to the data directory? (Y/n): ','y')
+		t.written_to_file('MMGen wallet')
+		return t
+
+	def ss_splt(self,tdir,ofmt,spec,add_args=[],wf=None,master=None):
+		try: os.mkdir(self.get_tmp_subdir(tdir))
+		except: pass
+		t = self.spawn('mmgen-seedsplit',
+				['-q','-d',self.get_tmp_subdir(tdir),'-r0','-o',ofmt]
+				+ (['-L',(spec or 'label')] if ofmt == 'w' else [])
+				+ add_args
+				+ (['--master-share={}'.format(master)] if master else [])
+				+ ([wf] if wf else [])
+				+ ([spec] if spec else []))
+		if not wf:
+			t.passphrase('MMGen wallet',wpasswd)
+		if spec:
+			from mmgen.obj import SeedSplitSpecifier
+			sss = SeedSplitSpecifier(spec)
+			pat = "Processing .* {} of {} of .* id '{}'".format(sss.idx,sss.count,sss.id)
+		else:
+			pat = "master share #{}".format(master)
+		t.expect(pat,regex=True)
+		if ofmt in ('w','incog','incog_hex','hincog'):
+			desc = {'w':         'MMGen wallet',
+					'incog':     'incognito data',
+					'incog_hex': 'hex incognito data',
+					'hincog':    'hidden incognito data' }[ofmt]
+			t.hash_preset('new '+desc,'1')
+			t.passphrase_new('new '+desc,sh1_passwd)
+			if desc == 'hidden incognito data':
+				t.hincog_create(1234)
+			t.written_to_file(capfirst(desc))
+		else:
+			t.written_to_file('data')
+		return t
+
+	def ss_join(self,tdir,ofmt,in_exts,add_args=[],sid=None,bad_invocation=False,master=None,id_str=None):
+		td = self.get_tmp_subdir(tdir)
+		shares = [get_file_with_ext(td,f) for f in in_exts]
+		if not sid:
+			sid = self.read_from_tmpfile('dfl.sid')
+		t = self.spawn('mmgen-seedjoin',
+				add_args
+				+ (['--master-share={}'.format(master)] if master else [])
+				+ (['--id-str={}'.format(id_str)] if id_str else [])
+				+ ['-d',td,'-o',ofmt]
+				+ (['--label','Joined Wallet Label','-r0'] if ofmt == 'w' else [])
+				+ shares)
+		if bad_invocation:
+			t.read()
+			return t
+		w_enc   = ( 'MMGen wallet' if 'mmdat' in in_exts else
+					'incognito data' if 'mmincog' in in_exts else
+					'hex incognito data' if 'mmincox' in in_exts else
+					'hidden incognito data' if '-H' in add_args else '')
+		if 'incognito' in w_enc:
+			t.hash_preset(w_enc,'1')
+		if w_enc:
+			t.passphrase(w_enc,sh1_passwd)
+		if master:
+			fs = "master share #{}, split id '{}', share count {}"
+			pat = fs.format(master,id_str or 'default',len(shares)+('hidden' in w_enc))
+			t.expect(pat,regex=True)
+		sid_cmp = t.expect_getend('Joined Seed ID: ')
+		cmp_or_die(sid,sid_cmp)
+		if ofmt == 'w':
+			t.hash_preset('new MMGen wallet','1')
+			t.passphrase_new('new MMGen wallet',wpasswd)
+			t.written_to_file('MMGen wallet')
+		else:
+			t.written_to_file('data')
+		return t
+
+	def get_hincog_arg(self,tdir,suf='-default-2of2'):
+		sid = self.read_from_tmpfile('dfl.sid')
+		return os.path.join(self.tmpdir,tdir,sid+suf+'.hincog') + ',123'
+
+	def ss_2way_A_dfl1(self):    return self.ss_splt('2way_dfl1','w','1:2')
+	def ss_2way_B_dfl1(self):    return self.ss_splt('2way_dfl1','bip39','2:2')
+	def ss_2way_join_dfl1(self): return self.ss_join('2way_dfl1','w',['mmdat','bip39'])
+
+	def ss_2way_A_dfl2(self):    return self.ss_splt('2way_dfl2','seed','default:1:2')
+	def ss_2way_B_dfl2(self):
+		return self.ss_splt('2way_dfl2','hincog','default:2:2',['-J',self.get_hincog_arg('2way_dfl2')])
+	def ss_2way_join_dfl2(self):
+		return self.ss_join('2way_dfl2','hex',['mmseed'],['-H',self.get_hincog_arg('2way_dfl2')])
+
+	def ss_2way_A_alice(self):        return self.ss_splt('2way_alice','w','alice:1:2')
+	def ss_2way_B_alice(self):        return self.ss_splt('2way_alice','hex','alice:2:2')
+	def ss_2way_join_alice(self):     return self.ss_join('2way_alice','seed',['mmdat','mmhex'])
+	def ss_2way_join_alice_mix(self): return self.ss_join('2way_alice','seed',['mmhex','mmdat'])
+
+	def ss_2way_A_dfl_usw(self):    return self.ss_splt('2way_dfl_usw','words','1:2',[],wf=ref_wf)
+	def ss_2way_B_dfl_usw(self):    return self.ss_splt('2way_dfl_usw','incog','2:2',[],wf=ref_wf)
+	def ss_2way_join_dfl_usw(self): return self.ss_join('2way_dfl_usw','hex',['mmwords','mmincog'],sid=ref_sid)
+
+	def ss_3way_A_dfl(self):        return self.ss_splt('3way_dfl','words','1:3')
+	def ss_3way_B_dfl(self):        return self.ss_splt('3way_dfl','incog_hex','2:3')
+	def ss_3way_C_dfl(self):        return self.ss_splt('3way_dfl','bip39','3:3')
+	def ss_3way_join_dfl(self):     return self.ss_join('3way_dfl','hex',['mmwords','mmincox','bip39'])
+	def ss_3way_join_dfl_mix(self): return self.ss_join('3way_dfl','hex',['bip39','mmwords','mmincox'])
+
+	def ss_2way_A_dfl_master3(self):
+		return self.ss_splt('2way_dfl_master3','w','',master=3)
+	def ss_2way_B_dfl_master3(self):
+		return self.ss_splt('2way_dfl_master3','bip39','2:2',master=3)
+	def ss_2way_join_dfl_master3(self):
+		return self.ss_join('2way_dfl_master3','hex',['mmdat','bip39'],master=3)
+
+	tdir2 = '3way_foobar_master7'
+	def ss_3way_C_foobar_master7(self):
+		return self.ss_splt(self.tdir2,'hincog','',
+					['-J',self.get_hincog_arg(self.tdir2,'-master7')],master=7)
+	def ss_3way_B_foobar_master7(self):
+		return self.ss_splt(self.tdir2,'bip39','φυβαρ:2:3',master=7)
+	def ss_3way_A_foobar_master7(self):
+		return self.ss_splt(self.tdir2,'hex','φυβαρ:3:3',master=7)
+	def ss_3way_join_foobar_master7(self):
+		return self.ss_join(self.tdir2,'seed', ['bip39','mmhex'],
+							['-H',self.get_hincog_arg(self.tdir2,'-master7')],master=7,id_str='φυβαρ')
+	def ss_3way_join_foobar_master7_mix(self):
+		return self.ss_join(self.tdir2,'seed', ['mmhex','bip39'],
+							['-H',self.get_hincog_arg(self.tdir2,'-master7')],master=7,id_str='φυβαρ')
+
+	def ss_bad_invocation(self,cmd,args,exit_val):
+		t = self.spawn(cmd,args)
+		t.read()
+		t.req_exit_val = exit_val
+		return t
+
+	def ss_3way_join_dfl_bad_invocation(self):
+		t = self.ss_join('3way_dfl','hex',
+				['mmwords','mmincox','bip39'],
+				id_str='foo',
+				bad_invocation=True)
+		t.req_exit_val = 1
+		return t
+
+	def ss_bad_invocation1(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',[],1)
+	def ss_bad_invocation2(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',['-M1','1:9'],1)
+	def ss_bad_invocation3(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',[self.tmpdir+'/no.mmdat','1:9'],1)
+	def ss_bad_invocation4(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',[self.tmpdir+'/dfl.sid','1:9'],1)
+	def ss_bad_invocation5(self):
+		return self.ss_bad_invocation('mmgen-seedjoin',[],1)
+	def ss_bad_invocation6(self):
+		return self.ss_bad_invocation('mmgen-seedjoin',[self.tmpdir+'/a'],1)
+	def ss_bad_invocation7(self):
+		return self.ss_bad_invocation('mmgen-seedjoin',[self.tmpdir+'/a',self.tmpdir+'/b'],1)
+	def ss_bad_invocation8(self):
+		return self.ss_bad_invocation('mmgen-seedjoin',[self.tmpdir+'/a.mmdat',self.tmpdir+'/b.mmdat'],1)
+	def ss_bad_invocation9(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',['x'],1)
+	def ss_bad_invocation10(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',[self.tmpdir+'/a.mmdat','1:2'],1)
+	def ss_bad_invocation11(self):
+		return self.ss_bad_invocation('mmgen-seedsplit',[self.tmpdir+'/dfl.sid','1:2'],1)

+ 23 - 0
test/tooltest2.py

@@ -330,6 +330,29 @@ tests = {
 				(md5_hash_strip,'996c047e8543d5dde6f82efc3214a6a1')
 				(md5_hash_strip,'996c047e8543d5dde6f82efc3214a6a1')
 			),
 			),
 		],
 		],
+		'list_shares': [
+			(   ['3','wallet=test/ref/98831F3A.bip39'],
+				(md5_hash_strip,'84e8bdaebf9c816a8a3bd2ebec5a2e12')
+			),
+			(   ['3','id_str=default','wallet=test/ref/98831F3A.mmwords'],
+				(md5_hash_strip,'84e8bdaebf9c816a8a3bd2ebec5a2e12')
+			),
+			(   ['3','id_str=foo','wallet=test/ref/98831F3A.bip39'],
+				(md5_hash_strip,'d2ac20823c4ea26f15234b5ca8df5d6f')
+			),
+			(   ['3','id_str=foo','master_share=0','wallet=test/ref/98831F3A.mmwords'],
+				(md5_hash_strip,'d2ac20823c4ea26f15234b5ca8df5d6f')
+			),
+			(   ['3','id_str=foo','master_share=5','wallet=test/ref/98831F3A.mmwords'],
+				(md5_hash_strip,'c4feedce40bb5959011ee4a996710832')
+			),
+			(   ['3','id_str=βαρ','master_share=5','wallet=test/ref/98831F3A.mmwords'],
+				(md5_hash_strip,'f7d254798fe2e34b94b5f4ff312998db')
+			),
+			(   ['4','id_str=βαρ','master_share=5','wallet=test/ref/98831F3A.bip39'],
+				(md5_hash_strip,'d3e479f55792181372a9f32a569c04e5')
+			),
+		],
 	},
 	},
 	'Coin': {
 	'Coin': {
 		'addr2pubhash': {
 		'addr2pubhash': {