Browse Source

The original implementation of mmgen-passgen, using sha256x10(seed+id_str)

Superseded by the current implementation using sha256x10(hmac(seed,id_str,hashlib.sha256))
philemon 7 years ago
parent
commit
85cf5b3cbb
19 changed files with 697 additions and 301 deletions
  1. 25 0
      mmgen-passgen
  2. 204 47
      mmgen/addr.py
  3. 7 1
      mmgen/crypto.py
  4. 2 2
      mmgen/filename.py
  5. 11 1
      mmgen/main.py
  6. 20 12
      mmgen/main_addrgen.py
  7. 138 0
      mmgen/main_passgen.py
  8. 60 74
      mmgen/obj.py
  9. 1 1
      mmgen/rpc.py
  10. 8 31
      mmgen/seed.py
  11. 31 42
      mmgen/tool.py
  12. 1 1
      mmgen/tw.py
  13. 3 8
      mmgen/tx.py
  14. 74 57
      mmgen/util.py
  15. 41 7
      setup.py
  16. 16 8
      test/gentest.py
  17. 17 0
      test/ref/98831F3A-фубар@crypto.org-base58-20[1,4,9-11,1100].pws
  18. 37 8
      test/test.py
  19. 1 1
      test/tooltest.py

+ 25 - 0
mmgen-passgen

@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2017 Philemon <mmgen-py@yandex.com>
+#
+# 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-passgen: Generate a series or range of passwords from an MMGen
+               deterministic wallet
+"""
+
+from mmgen.main import launch
+launch("passgen")

+ 204 - 47
mmgen/addr.py

@@ -45,7 +45,7 @@ package on your system or specify the secp256k1 library.
 def _test_for_secp256k1(silent=False):
 	no_secp256k1_errmsg = """
 secp256k1 library unavailable.  Will use '{kconv}', or failing that, the (slow)
-internal ECDSA library for address generation.
+native Python ECDSA library for address generation.
 """.format(kconv=g.keyconv_exec)
 	try:
 		from mmgen.secp256k1 import priv2pub
@@ -94,7 +94,7 @@ def _keygen_selector(generator=None):
 	else:
 		if opt.key_generator == 3 and _test_for_secp256k1():     return 2
 		elif opt.key_generator in (2,3) and _test_for_keyconv(): return 1
-	msg('Using (slow) internal ECDSA library for address generation')
+	msg('Using (slow) native Python ECDSA library for address generation')
 	return 0
 
 def get_wif2addr_f(generator=None):
@@ -107,21 +107,22 @@ def get_privhex2addr_f(generator=None):
 
 class AddrListEntry(MMGenListItem):
 	attrs = 'idx','addr','label','wif','sec'
-	label = MMGenListItemAttr('label','MMGenAddrLabel')
 	idx   = MMGenListItemAttr('idx','AddrIdx')
 
 class AddrListChksum(str,Hilite):
 	color = 'pink'
 	trunc_ok = False
+
 	def __new__(cls,addrlist):
-		lines=[' '.join([str(e.idx),e.addr]+([e.wif] if addrlist.has_keys else []))
-						for e in addrlist.data]
+		els = ['addr','wif'] if addrlist.has_keys else ['sec'] if addrlist.gen_passwds else ['addr']
+		lines = [' '.join([str(e.idx)] + [getattr(e,f) for f in els]) for e in addrlist.data]
+#		print '[{}]'.format(' '.join(lines))
 		return str.__new__(cls,make_chksum_N(' '.join(lines), nchars=16, sep=True))
 
-class AddrListID(str,Hilite):
+class AddrListID(unicode,Hilite):
 	color = 'green'
 	trunc_ok = False
-	def __new__(cls,addrlist):
+	def __new__(cls,addrlist,fmt_str=None):
 		try: int(addrlist.data[0].idx)
 		except:
 			s = '(no idxs)'
@@ -136,8 +137,8 @@ class AddrListID(str,Hilite):
 					if prev != ret[-1]: ret += '-', prev
 					ret += ',', i
 				prev = i
-			s = ''.join([str(i) for i in ret])
-		return str.__new__(cls,'%s[%s]' % (addrlist.seed_id,s))
+			s = ''.join([unicode(i) for i in ret])
+		return unicode.__new__(cls,fmt_str.format(s) if fmt_str else '{}[{}]'.format(addrlist.seed_id,s))
 
 class AddrList(MMGenObject): # Address info for a single seed ID
 	msgs = {
@@ -158,11 +159,13 @@ Record this checksum: it will be used to verify the address file in the future
 Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 """.strip().format(pnm=pnm)
 	}
+	main_key  = 'addr'
 	data_desc = 'address'
 	file_desc = 'addresses'
-	gen_desc = 'address'
+	gen_desc  = 'address'
 	gen_desc_pl = 'es'
 	gen_addrs = True
+	gen_passwds = False
 	gen_keys = False
 	has_keys = False
 	ext      = 'addrs'
@@ -199,17 +202,15 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 		self.fmt_data = ''
 		self.id_str = None
 		self.chksum = None
+		self.id_str = AddrListID(self)
 
-		if type(self) == KeyList:
-			self.id_str = AddrListID(self)
-			return
+		if type(self) == KeyList: return
 
 		if do_chksum:
 			self.chksum = AddrListChksum(self)
 			if chksum_only:
 				Msg(self.chksum)
 			else:
-				self.id_str = AddrListID(self)
 				qmsg('Checksum for %s data %s: %s' %
 						(self.data_desc,self.id_str.hl(),self.chksum.hl()))
 				qmsg(self.msgs[('check_chksum','record_chksum')[src=='gen']])
@@ -225,6 +226,8 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 		self.seed_id = SeedID(seed=seed)
 		seed = seed.get_data()
 
+		seed = self.cook_seed(seed)
+
 		if self.gen_addrs:
 			privhex2addr_f = get_privhex2addr_f()
 
@@ -238,7 +241,8 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 
 			pos += 1
 
-			qmsg_r('\rGenerating %s #%s (%s of %s)' % (self.gen_desc,num,pos,t_addrs))
+			if not g.debug:
+				qmsg_r('\rGenerating %s #%s (%s of %s)' % (self.gen_desc,num,pos,t_addrs))
 
 			e = AddrListEntry(idx=num)
 
@@ -252,19 +256,27 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 				e.wif = hex2wif(sec,compressed=False)
 				if opt.b16: e.sec = sec
 
+			if self.gen_passwds:
+				e.sec = self.make_passwd(sec)
+				dmsg('Key {:>03}: {}'.format(pos,sec))
+
 			out.append(e)
 
 		qmsg('\r%s: %s %s%s generated%s' % (
 				self.seed_id.hl(),t_addrs,self.gen_desc,suf(t_addrs,self.gen_desc_pl),' '*15))
 		return out
 
-	def encrypt(self):
+	def chk_addr_or_pw(self,addr): return is_btc_addr(addr)
+
+	def cook_seed(self,seed): return seed
+
+	def encrypt(self,desc='new key list'):
 		from mmgen.crypto import mmgen_encrypt
-		self.fmt_data = mmgen_encrypt(self.fmt_data,'new key list','')
+		self.fmt_data = mmgen_encrypt(self.fmt_data.encode('utf8'),desc,'')
 		self.ext += '.'+g.mmenc_ext
 
 	def write_to_file(self,ask_tty=True,ask_write_default_yes=False,binary=False):
-		fn = '{}.{}'.format(self.id_str,self.ext)
+		fn = u'{}.{}'.format(self.id_str,self.ext)
 		ask_tty = self.has_keys and not opt.quiet
 		write_data_to_file(fn,self.fmt_data,self.file_desc,ask_tty=ask_tty,binary=binary)
 
@@ -359,23 +371,31 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 				if not getattr(e,key):
 					die(3,'missing %s in addr data' % desc)
 
-		if type(self) != KeyList: check_attrs('addr','addresses')
+		if type(self) not in (KeyList,PasswordList): check_attrs('addr','addresses')
+
 		if self.has_keys:
 			if opt.b16: check_attrs('sec','hex keys')
 			check_attrs('wif','wif keys')
 
 		out = [self.msgs['file_header']+'\n']
 		if self.chksum:
-			out.append('# {} data checksum for {}: {}'.format(
-						self.data_desc.capitalize(),self.id_str,self.chksum))
+			out.append(u'# {} data checksum for {}: {}'.format(
+						capfirst(self.data_desc),self.id_str,self.chksum))
 			out.append('# Record this value to a secure location.\n')
-		out.append('%s {' % self.seed_id)
+
+		if type(self) == PasswordList:
+			out.append(u'{} {} {}:{} {{'.format(
+				self.seed_id,self.pw_id_str,self.pw_fmt,self.pw_len))
+		else:
+			out.append('{} {{'.format(self.seed_id))
 
 		fs = '  {:<%s}  {:<34}{}' % len(str(self.data[-1].idx))
 		for e in self.data:
 			c = ' '+e.label if enable_comments and e.label else ''
 			if type(self) == KeyList:
 				out.append(fs.format(e.idx, 'wif: '+e.wif,c))
+			elif type(self) == PasswordList:
+				out.append(fs.format(e.idx, e.sec, c))
 			else: # First line with idx
 				out.append(fs.format(e.idx, e.addr,c))
 				if self.has_keys:
@@ -391,18 +411,20 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 			return 'Key-address file has odd number of lines'
 
 		ret = []
+
 		while lines:
 			l = lines.pop(0)
 			d = l.split(None,2)
 
 			if not is_mmgen_idx(d[0]):
 				return "'%s': invalid address num. in line: '%s'" % (d[0],l)
-			if not is_btc_addr(d[1]):
-				return "'%s': invalid Bitcoin address" % d[1]
+
+			if not self.chk_addr_or_pw(d[1]):
+				return "'{}': invalid {}".format(d[1],self.data_desc)
 
 			if len(d) != 3: d.append('')
 
-			a = AddrListEntry(idx=int(d[0]),addr=d[1],label=d[2])
+			a = AddrListEntry(**{'idx':int(d[0]),self.main_key:d[1],'label':d[2]})
 
 			if self.has_keys:
 				l = lines.pop(0)
@@ -430,30 +452,40 @@ Removed %s duplicate wif key%s from keylist (also in {pnm} key-address file
 
 	def parse_file(self,fn,buf=[],exit_on_error=True):
 
-		lines = get_lines_from_file(fn,self.data_desc+' data',trim_comments=True)
+		def do_error(msg):
+			if exit_on_error: die(3,msg)
+			msg(msg)
+			return False
 
-		try:
-			sid,obrace = lines[0].split()
-		except:
-			errmsg = "Invalid first line: '%s'" % lines[0]
-		else:
-			cbrace = lines[-1]
-			if obrace != '{':
-				errmsg = "'%s': invalid first line" % lines[0]
-			elif cbrace != '}':
-				errmsg = "'%s': invalid last line" % cbrace
-			elif not is_mmgen_seed_id(sid):
-				errmsg = "'%s': invalid Seed ID" % sid
-			else:
-				ret = self.parse_file_body(lines[1:-1])
-				if type(ret) == list:
-					return sid,ret
-				else:
-					errmsg = ret
+		lines = get_lines_from_file(fn,self.data_desc+' data',trim_comments=True)
 
-		if exit_on_error: die(3,errmsg)
-		msg(errmsg)
-		return False
+		if len(lines) < 3:
+			return do_error("Too few lines in address file (%s)" % len(lines))
+
+		ls = lines[0].split()
+		ls_len = (2,4)[type(self)==PasswordList]
+		if len(ls) != ls_len:
+			return do_error("Invalid first line for {} file: '{}'".format(self.gen_desc,lines[0]))
+		if ls[-1] != '{':
+			return do_error("'%s': invalid first line" % ls)
+		if lines[-1] != '}':
+			return do_error("'%s': invalid last line" % lines[-1])
+		if not is_mmgen_seed_id(ls[0]):
+			return do_error("'%s': invalid Seed ID" % ls[0])
+
+		if type(self) == PasswordList:
+			self.pw_id_str = MMGenPWIDString(ls[1])
+			ss = ls[2].split(':')
+			if len(ss) != 2:
+				return do_error("'%s': invalid password length specifier (must contain colon)" % ls[2])
+			self.set_pw_fmt(ss[0])
+			self.set_pw_len(ss[1])
+
+		ret = self.parse_file_body(lines[1:-1])
+		if type(ret) != list:
+			return do_error(ret)
+
+		return ls[0],ret
 
 class KeyAddrList(AddrList):
 	data_desc = 'key-address'
@@ -483,6 +515,131 @@ class KeyList(AddrList):
 	has_keys = True
 	ext      = 'keys'
 
+class PasswordList(AddrList):
+	msgs = {
+	'file_header': """
+# {pnm} password file
+#
+# This file is editable.
+# Everything following a hash symbol '#' is a comment and ignored by {pnm}.
+# A text label of {n} characters or less may be added to the right of each
+# password.  The label may contain any printable ASCII symbol.
+#
+""".strip().format(n=MMGenAddrLabel.max_len,pnm=pnm),
+	'record_chksum': """
+Record this checksum: it will be used to verify the password file in the future
+""".strip()
+	}
+	main_key    = 'sec'
+	data_desc   = 'password'
+	file_desc   = 'passwords'
+	gen_desc    = 'password'
+	gen_desc_pl = 's'
+	gen_addrs   = False
+	gen_keys    = False
+	gen_passwds = True
+	has_keys    = False
+	ext         = 'pws'
+	pw_len      = None
+	pw_fmt      = None
+	pw_info     = {
+		'base58': { 'min_len': 8 , 'max_len': 36 ,'dfl_len': 20, 'desc': 'base-58 password' },
+		'base32': { 'min_len': 10 ,'max_len': 42 ,'dfl_len': 24, 'desc': 'base-32 password' }
+		}
+	cook_hash_rounds = 10  # not too many rounds, so hand decoding can still be feasible
+
+	def __init__(self,
+				seed=None,
+				addr_idxs=None,
+				pw_id_str=None,
+				pw_len=None,
+				infile=None,
+				chksum_only=False,
+				pw_fmt=None,
+				chk_params_only=False
+				):
+
+		self.update_msgs()
+
+		if infile:
+			(self.seed_id,self.data) = self.parse_file(infile) # sets self.pw_id_str,self.pw_fmt,self.pw_len
+		else:
+			for k in seed,addr_idxs: assert chk_params_only or k
+			for k in pw_id_str,pw_fmt: assert k
+			self.pw_id_str = MMGenPWIDString(pw_id_str)
+			self.set_pw_fmt(pw_fmt)
+			self.set_pw_len(pw_len)
+			if chk_params_only: return
+			self.seed_id = seed.sid
+			self.data = self.generate(seed,addr_idxs)
+
+		self.num_addrs = len(self.data)
+		self.fmt_data = ''
+		self.chksum = AddrListChksum(self)
+
+		if chksum_only:
+			Msg(self.chksum)
+		else:
+			self.id_str = AddrListID(self,fmt_str=u'{}-{}-{}-{}[{{}}]'.format(
+				self.seed_id,self.pw_id_str,self.pw_fmt,self.pw_len))
+			qmsg(u'Checksum for {} data {}: {}'.format(self.data_desc,self.id_str.hl(),self.chksum.hl()))
+			qmsg(self.msgs[('record_chksum','check_chksum')[bool(infile)]])
+
+	def set_pw_fmt(self,pw_fmt):
+		assert pw_fmt in self.pw_info
+		self.pw_fmt = pw_fmt
+
+	def chk_pw_len(self,passwd=None):
+		if passwd is None:
+			assert self.pw_len
+			pw_len = self.pw_len
+			fs = '{l}: invalid user-requested length for {b} ({c}{m})'
+		else:
+			pw_len = len(passwd)
+			fs = '{pw}: {b} has invalid length {l} ({c}{m} characters)'
+		d = self.pw_info[self.pw_fmt]
+		if pw_len > d['max_len']:
+			die(2,fs.format(l=pw_len,b=d['desc'],c='>',m=d['max_len'],pw=passwd))
+		elif pw_len < d['min_len']:
+			die(2,fs.format(l=pw_len,b=d['desc'],c='<',m=d['min_len'],pw=passwd))
+
+	def set_pw_len(self,pw_len):
+		assert self.pw_fmt in self.pw_info
+		d = self.pw_info[self.pw_fmt]
+
+		if pw_len is None:
+			self.pw_len = d['dfl_len']
+			return
+
+		if not is_int(pw_len):
+			die(2,"'{}': invalid user-requested password length (not an integer)".format(pw_len,d['desc']))
+		self.pw_len = int(pw_len)
+		self.chk_pw_len()
+
+	def make_passwd(self,hex_sec):
+		assert self.pw_fmt in self.pw_info
+		from mmgen.bitcoin import b58a
+		alpha,base = ((b58a,58),(b32a,32))[self.pw_fmt=='base32']
+		# we take least significant part
+		return ''.join(baseconv.fromhex(base,hex_sec,alpha,pad=self.pw_len))[-self.pw_len:]
+
+	def chk_addr_or_pw(self,pw):
+		if not (is_b58_str,is_b32_str)[self.pw_fmt=='base32'](pw):
+			msg('Password is not a valid {} string'.format(self.pw_fmt))
+			return False
+		if len(pw) != self.pw_len:
+			msg('Password has incorrect length ({} != {})'.format(len(pw),self.pw_len))
+			return False
+		return True
+
+	def cook_seed(self,seed):
+		from mmgen.crypto import sha256_rounds
+		# Changing either pw_fmt or pw_len will cause a different, unrelated set of passwords to
+		# be generated: this is what we want
+		cseed = '{}{}:{}:{}'.format(seed,self.pw_fmt,self.pw_len,self.pw_id_str.encode('utf8'))
+		dmsg('Cooked seed: {}\nSeed len: {}'.format(repr(cseed),len(cseed)))
+		return sha256_rounds(cseed,self.cook_hash_rounds)
+
 
 class AddrData(MMGenObject):
 	msgs = {

+ 7 - 1
mmgen/crypto.py

@@ -53,6 +53,12 @@ keystrokes will also be used as a source of randomness.
 # """.strip(),
 }
 
+def sha256_rounds(s,n):
+	assert is_int(n) and n > 0
+	for i in range(n):
+		s = sha256(s).digest()
+	return s
+
 def encrypt_seed(seed, key):
 	return encrypt_data(seed, key, iv=1, desc='seed')
 
@@ -237,7 +243,7 @@ def mmgen_decrypt(data,desc='data',hash_preset=''):
 	m = ('user-requested','default')[hp=='3']
 	qmsg("Using %s hash preset of '%s'" % (m,hp))
 	passwd = get_mmgen_passphrase(desc)
-	key = make_key(passwd, salt, hp)
+	key = make_key(passwd,salt,hp)
 	dec_d = decrypt_data(enc_d, key, int(hexlify(iv),16), desc)
 	if dec_d[:sha256_len] == sha256(dec_d[sha256_len:]).digest():
 		vmsg('OK')

+ 2 - 2
mmgen/filename.py

@@ -74,13 +74,13 @@ def find_files_in_dir(ftype,fdir,no_dups=False):
 		die(3,"'{}': not a recognized file type".format(ftype))
 
 	try: dirlist = os.listdir(fdir)
-	except: die(3,"ERROR: unable to read directory '{}'".format(fdir))
+	except: die(3,u"ERROR: unable to read directory '{}'".format(fdir))
 
 	matches = [l for l in dirlist if l[-len(ftype.ext)-1:]=='.'+ftype.ext]
 
 	if no_dups:
 		if len(matches) > 1:
-			die(1,"ERROR: more than one {} file in directory '{}'".format(ftype.__name__,fdir))
+			die(1,u"ERROR: more than one {} file in directory '{}'".format(ftype.__name__,fdir))
 		return os.path.join(fdir,matches[0]) if len(matches) else None
 	else:
 		return [os.path.join(fdir,m) for m in matches]

+ 11 - 1
mmgen/main.py

@@ -22,6 +22,16 @@ main.py - Script launcher for the MMGen suite
 
 def launch(what):
 
+	def my_dec(a):
+		try:
+			return a.decode('utf8')
+		except:
+			sys.stderr.write("Argument '{}' is not a valid UTF-8 string".format(a))
+			sys.exit(2)
+
+	import sys
+	sys.argv = [my_dec(a) for a in sys.argv]
+
 	if what in ('walletgen','walletchk','walletconv','passchg'):
 		what = 'wallet'
 	if what == 'keygen': what = 'addrgen'
@@ -30,7 +40,7 @@ def launch(what):
 	except: # Windows
 		__import__('mmgen.main_' + what)
 	else:
-		import sys,os,atexit
+		import os,atexit
 		if sys.stdin.isatty():
 			fd = sys.stdin.fileno()
 			old = termios.tcgetattr(fd)

+ 20 - 12
mmgen/main_addrgen.py

@@ -28,23 +28,24 @@ from mmgen.seed import SeedSource
 
 if sys.argv[0].split('-')[-1] == 'keygen':
 	gen_what = 'keys'
+	gen_desc = 'secret keys'
 	opt_filter = None
-	note1 = """
-By default, both addresses and secret keys are generated.
-""".strip()
+	note2 = 'By default, both addresses and secret keys are generated.\n\n'
 else:
 	gen_what = 'addresses'
+	gen_desc = 'addresses'
 	opt_filter = 'hbcdeiHOKlpzPqrSv-'
-	note1 = """
-If available, the external 'keyconv' program will be used for address
-generation.
+	note2 = ''
+note1 = """
+If available, the secp256k1 library will be used for address generation.
 """.strip()
 
+
 opts_data = {
 	'sets': [('print_checksum',True,'quiet',True)],
-	'desc': """Generate a range or list of {what} from an {pnm} wallet,
-                  mnemonic, seed or password""".format(what=gen_what,pnm=g.proj_name),
-	'usage':'[opts] [infile] <range or list of address indexes>',
+	'desc': """Generate a range or list of {desc} from an {pnm} wallet,
+                  mnemonic, seed or brainwallet""".format(desc=gen_desc,pnm=g.proj_name),
+	'usage':'[opts] [seed source] <index list or range(s)>',
 	'options': """
 -h, --help            Print this help message
 --, --longhelp        Print help message for long options (common options)
@@ -80,9 +81,16 @@ opts_data = {
 ),
 	'notes': """
 
-Address indexes are given in a comma-separated list and/or hyphen-separated ranges.
 
-{n}
+                           NOTES FOR THIS COMMAND
+
+Address indexes are given as a comma-separated list and/or hyphen-separated
+range(s).
+
+{n2}{n1}
+
+
+                      NOTES FOR ALL GENERATOR COMMANDS
 
 {o.pw_note}
 
@@ -91,7 +99,7 @@ Address indexes are given in a comma-separated list and/or hyphen-separated rang
 FMT CODES:
   {f}
 """.format(
-		n=note1,
+		n1=note1,n2=note2,
 		f='\n  '.join(SeedSource.format_fmt_codes().splitlines()),
 		o=opts
 	)

+ 138 - 0
mmgen/main_passgen.py

@@ -0,0 +1,138 @@
+#!/usr/bin/env python
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2017 Philemon <mmgen-py@yandex.com>
+#
+# 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-passgen: Generate a series or range of passwords from an MMGen
+               deterministic wallet
+"""
+
+from mmgen.common import *
+from mmgen.crypto import *
+from mmgen.addr import PasswordList,AddrIdxList
+from mmgen.seed import SeedSource
+from mmgen.obj import MMGenPWIDString
+
+opts_data = {
+	'sets': [('print_checksum',True,'quiet',True)],
+	'desc': """Generate a range or list of passwords from an {pnm} wallet,
+                  mnemonic, seed or brainwallet for the given ID string""".format(pnm=g.proj_name),
+	'usage':'[opts] [seed source] <ID string> <index list or range(s)>',
+	'options': """
+-h, --help            Print this help message
+--, --longhelp        Print help message for long options (common options)
+-b, --base32          Generate passwords in Base32 format instead of Base58
+-d, --outdir=      d  Output files to directory 'd' instead of working dir
+-e, --echo-passphrase Echo passphrase or mnemonic to screen upon entry
+-i, --in-fmt=      f  Input is from wallet format 'f' (see FMT CODES below)
+-H, --hidden-incog-input-params=f,o  Read hidden incognito data from file
+                      'f' at offset 'o' (comma-separated)
+-O, --old-incog-fmt   Specify old-format incognito input
+-L, --passwd-len=  l  Specify length of generated passwords
+                      (default: {p} chars [base58], {q} chars [base32])
+-l, --seed-len=    l  Specify wallet seed length of 'l' bits.  This option
+                      is required only for brainwallet and incognito inputs
+                      with non-standard (< {g.seed_len}-bit) seed lengths
+-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          Print passwords to stdout
+-v, --verbose         Produce more verbose output
+""".format(
+	seed_lens=', '.join([str(i) for i in g.seed_lens]),
+	pnm=g.proj_name,
+	kgs=' '.join(['{}:{}'.format(n,k) for n,k in enumerate(g.key_generators,1)]),
+	g=g,
+	p=PasswordList.pw_info['base58']['dfl_len'],
+	q=PasswordList.pw_info['base32']['dfl_len']
+),
+	'notes': """
+
+
+                           NOTES FOR THIS COMMAND
+
+ID string must be a valid UTF-8 string not longer than {ml} characters and
+not containing the symbols '{fs}'.
+
+Password indexes are given as a comma-separated list and/or hyphen-separated
+range(s).
+
+Changing either the password format (base32,base58) or length alters the seed
+and thus generates a completely new set of passwords.
+
+EXAMPLE:
+  Generate ten base58 passwords of length {dfl58} for Alice's email account:
+  {g.prog_name} alice@nowhere.com 1-10
+
+  Generate ten base58 passwords of length 16 for Alice's email account:
+  {g.prog_name} -L16 alice@nowhere.com 1-10
+
+  Generate ten base32 passwords of length {dfl32} for Alice's email account:
+  {g.prog_name} -b alice@nowhere.com 1-10
+
+  The three sets of passwords are completely unrelated to each other, so
+  Alice doesn't need to worry about password reuse.
+
+
+                      NOTES FOR ALL GENERATOR COMMANDS
+
+{o.pw_note}
+
+{o.bw_note}
+
+FMT CODES:
+  {f}
+""".format(
+		f='\n  '.join(SeedSource.format_fmt_codes().splitlines()),
+		o=opts,g=g,
+		ml=MMGenPWIDString.max_len,
+		dfl58=PasswordList.pw_info['base58']['dfl_len'],
+		dfl32=PasswordList.pw_info['base32']['dfl_len'],
+		fs="', '".join(MMGenPWIDString.forbidden)
+	)
+}
+
+cmd_args = opts.init(opts_data,add_opts=['b16'])
+
+if len(cmd_args) < 2: opts.usage()
+
+idxs = AddrIdxList(fmt_str=cmd_args.pop())
+
+pw_id_str = cmd_args.pop()
+
+sf = get_seed_file(cmd_args,1)
+
+pw_fmt = ('base58','base32')[bool(opt.base32)]
+
+PasswordList(pw_id_str=pw_id_str,pw_len=opt.passwd_len,pw_fmt=pw_fmt,chk_params_only=True)
+do_license_msg()
+
+ss = SeedSource(sf)
+
+al = PasswordList(seed=ss.seed,addr_idxs=idxs,pw_id_str=pw_id_str,pw_len=opt.passwd_len,pw_fmt=pw_fmt)
+
+al.format()
+
+if keypress_confirm('Encrypt password list?'):
+	al.encrypt(desc='password list')
+	al.write_to_file(binary=True)
+else:
+	al.write_to_file()

+ 60 - 74
mmgen/obj.py

@@ -17,7 +17,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 """
-obj.py:  The MMGenObject class and methods
+obj.py:  MMGen native classes
 """
 
 from decimal import *
@@ -26,72 +26,39 @@ lvl = 0
 
 class MMGenObject(object):
 
-	# Pretty-print any object of type MMGenObject, recursing into sub-objects
-	def __str__(self):
-		global lvl
-		indent = lvl * '    '
-
-		def fix_linebreaks(v,fixed_indent=None):
-			if '\n' in v:
-				i = indent+'    ' if fixed_indent == None else fixed_indent*' '
-				return '\n'+i + v.replace('\n','\n'+i)
-			else: return repr(v)
-
-		def conv(v,col_w):
-			vret = ''
-			if type(v) in (str,unicode):
-				from string import printable
-				if not (set(list(v)) <= set(list(printable))):
-					vret = repr(v)
+	# Pretty-print any object of type MMGenObject, recursing into sub-objects - WIP
+	def pprint(self):  print self.pformat()
+	def pformat(self,lvl=0):
+		def do_list(out,e,lvl=0):
+			add_spc = False
+			if e and type(e[0]) not in (str,unicode):
+				out.append('\n')
+			for i in e:
+				if hasattr(i,'pformat'):
+					out.append('{:>{l}}{}'.format('',i.pformat(lvl=lvl+1),l=(lvl+1)*8))
+				elif type(i) in (str,unicode):
+					add_spc = True
+					out.append(u' {}'.format(repr(i)))
+				elif type(i) == list:
+					out.append(u'{:>{l}}{:16}'.format('','<'+type(i).__name__+'>',l=(lvl*8)+4))
+					do_list(out,i,lvl=lvl)
 				else:
-					vret = fix_linebreaks(v,fixed_indent=0)
-			elif type(v) in (int,long,BTCAmt):
-				vret = str(v)
-			elif type(v) == dict:
-				sep = '\n{}{}'.format(indent,' '*4)
-				cw = (max(len(k) for k in v) if v else 0) + 2
-				t = sep.join(['{:<{w}}: {}'.format(
-					repr(k),
-	(fix_linebreaks(v[k],fixed_indent=0) if type(v[k]) == str else v[k]),
-					w=cw)
-				for k in sorted(v)])
-				vret = '{' + sep + t + '\n' + indent + '}'
-			elif type(v) in (list,tuple):
-				sep = '\n{}{}'.format(indent,' '*4)
-				t = ' '.join([repr(e) for e in sorted(v)])
-				o,c = (('(',')'),('[',']'))[type(v)==list]
-				vret = o + sep + t + '\n' + indent + c
-			elif repr(v)[:14] == '<bound method ':
-				vret = ' '.join(repr(v).split()[0:3]) + '>'
-#				vret = repr(v)
-
-			return vret or type(v)
-
+					out.append(u'{:>{l}}{:16} {}\n'.format('','<'+type(i).__name__+'>',repr(i),l=(lvl*8)+8))
+			if not e: out.append('{}\n'.format(repr(e)))
+			if add_spc: out.append('\n')
 		out = []
-		def f(k): return k[:2] != '__'
-		keys = filter(f, self.__dict__.keys())
-		col_w = max(len(k) for k in keys) if keys else 1
-		fs = '{}%-{}s: %s'.format(indent,col_w)
-
-		methods = [k for k in keys if repr(getattr(self,k))[:14] == '<bound method ']
-
-		def f(k): return repr(getattr(self,k))[:14] == '<bound method '
-		methods = filter(f,keys)
-		def f(k): return repr(getattr(self,k))[:7] == '<mmgen.'
-		objects = filter(f,keys)
-		other = list(set(keys) - set(methods) - set(objects))
-
-		for k in sorted(methods) + sorted(other) + sorted(objects):
-			val = getattr(self,k)
-			if str(type(val))[:13] == "<class 'mmgen": # recurse into sub-objects
-				out.append('\n%s%s (%s):' % (indent,k,type(val)))
-				lvl += 1
-				out.append(unicode(getattr(self,k))+'\n')
-				lvl -= 1
+		out.append(u'<{}>\n'.format(type(self).__name__))
+		d = self.__dict__
+		for k in d:
+			e = getattr(self,k)
+			if type(e) == list:
+				out.append(u'{:>{l}}{:<10} {:16}'.format('',k,'<'+type(e).__name__+'>',l=(lvl*8)+4))
+				do_list(out,e,lvl=lvl)
+			elif hasattr(e,'pformat') and type(e) != type:
+				out.append(u'{:>{l}}{:10} {}'.format('',k,e.pformat(lvl=lvl+1),l=(lvl*8)+4))
 			else:
-				out.append(fs % (k, conv(val,col_w)))
-
-		return repr(self) + '\n    ' + '\n    '.join(out)
+				out.append(u'{:>{l}}{:<10} {:16} {}\n'.format('',k,'<'+type(e).__name__+'>',repr(e),l=(lvl*8)+4))
+		return ''.join(out)
 
 # Descriptor: https://docs.python.org/2/howto/descriptor.html
 class MMGenListItemAttr(object):
@@ -124,7 +91,7 @@ class MMGenListItem(MMGenObject):
 			"'{}': attribute '{}' in instance of class '{}' cannot be reassigned".format(
 				val,attr,type(self).__name__)
 
-	attrs_base = ('attrs','attrs_priv','attrs_reassign','attrs_base','attr_error','set_error','__dict__')
+	attrs_base = ('attrs','attrs_priv','attrs_reassign','attrs_base','attr_error','set_error','__dict__','pformat')
 
 	def __init__(self,*args,**kwargs):
 		if args:
@@ -432,36 +399,55 @@ class BitcoinTxID(MMGenTxID):
 class MMGenLabel(unicode,Hilite,InitErrors):
 
 	color = 'pink'
-	allowed = u''
+	allowed = []
+	forbidden = []
 	max_len = 0
+	min_len = 0
 	desc = 'label'
 
 	def __new__(cls,s,on_fail='die',msg=None):
 		cls.arg_chk(cls,on_fail)
+		for k in cls.forbidden,cls.allowed:
+			assert type(k) == list
+			for ch in k: assert type(ch) == unicode and len(ch) == 1
 		try:
-			s = s.decode('utf8').strip()
+			s = s.strip()
+			if type(s) != unicode:
+				s = s.decode('utf8')
 		except:
-			m = "'%s: value is not a valid UTF-8 string" % s
+			m = "'%s': value is not a valid UTF-8 string" % s
 		else:
+			from mmgen.util import capfirst
 			if len(s) > cls.max_len:
-				m = '%s too long (>%s symbols)' % (cls.desc.capitalize(),cls.max_len)
-			elif cls.allowed and not set(list(s)).issubset(set(list(cls.allowed))):
-				m = '%s contains non-permitted symbols: %s' % (cls.desc.capitalize(),
-					' '.join(set(list(s)) - set(list(cls.allowed))))
+				m = u"'{}': {} too long (>{} symbols)".format(s,capfirst(cls.desc),cls.max_len)
+			elif len(s) < cls.min_len:
+				m = u"'{}': {} too short (<{} symbols)".format(s,capfirst(cls.desc),cls.min_len)
+			elif cls.allowed and not set(list(s)).issubset(set(cls.allowed)):
+				m = u"{} '{}' contains non-allowed symbols: {}".format(capfirst(cls.desc),s,
+					' '.join(set(list(s)) - set(cls.allowed)))
+			elif cls.forbidden and any([ch in s for ch in cls.forbidden]):
+				m = u"{} '{}' contains one of these forbidden symbols: '{}'".format(capfirst(cls.desc),s,
+					"', '".join(cls.forbidden))
 			else:
 				return unicode.__new__(cls,s)
 		return cls.init_fail((msg+'\n' if msg else '') + m,on_fail)
 
 class MMGenWalletLabel(MMGenLabel):
 	max_len = 48
-	allowed = [chr(i+32) for i in range(95)]
+	allowed = [unichr(i+32) for i in range(95)]
 	desc = 'wallet label'
 
 class MMGenAddrLabel(MMGenLabel):
 	max_len = 32
-	allowed = [chr(i+32) for i in range(95)]
+	allowed = [unichr(i+32) for i in range(95)]
 	desc = 'address label'
 
 class MMGenTXLabel(MMGenLabel):
 	max_len = 72
 	desc = 'transaction label'
+
+class MMGenPWIDString(MMGenLabel):
+	max_len = 256
+	min_len = 1
+	desc = 'password ID string'
+	forbidden = list(u' :/\\')

+ 1 - 1
mmgen/rpc.py

@@ -104,7 +104,7 @@ class BitcoinRPCConnection(object):
 				'Authorization': 'Basic {}'.format(base64.b64encode(self.auth_str))
 			})
 		except Exception as e:
-			return die_maybe(None,2,'%s\nUnable to connect to bitcoind' % e)
+			return die_maybe(None,2,'{}\nUnable to connect to bitcoind at {}:{}'.format(e,self.host,self.port))
 
 		r = hc.getresponse() # returns HTTPResponse instance
 

+ 8 - 31
mmgen/seed.py

@@ -370,38 +370,15 @@ class Mnemonic (SeedSourceUnenc):
 	@staticmethod
 	def _hex2mn_pad(hexnum): return len(hexnum) * 3 / 8
 
-	@staticmethod
-	def baseNtohex(base,words_arg,wl,pad=0): # accepts both string and list input
-		words = words_arg
-		if type(words) not in (list,tuple):
-			words = tuple(words.strip())
-		if not set(words).issubset(set(wl)):
-			die(2,'{} is not in base-{} format'.format(repr(words_arg),base))
-		deconv =  [wl.index(words[::-1][i])*(base**i)
-					for i in range(len(words))]
-		ret = ('{:0%sx}' % pad).format(sum(deconv))
-		return ('','0')[len(ret) % 2] + ret
-
-	@staticmethod
-	def hextobaseN(base,hexnum,wl,pad=0):
-		hexnum = hexnum.strip()
-		if not is_hexstring(hexnum):
-			die(2,"'%s': not a hexadecimal number" % hexnum)
-		num,ret = int(hexnum,16),[]
-		while num:
-			ret.append(num % base)
-			num /= base
-		return [wl[n] for n in [0] * (pad-len(ret)) + ret[::-1]]
-
 	@classmethod
 	def hex2mn(cls,hexnum,wordlist):
 		wl = cls.get_wordlist(wordlist)
-		return cls.hextobaseN(cls.mn_base,hexnum,wl,cls._hex2mn_pad(hexnum))
+		return baseconv.fromhex(cls.mn_base,hexnum,wl,cls._hex2mn_pad(hexnum))
 
 	@classmethod
 	def mn2hex(cls,mn,wordlist):
 		wl = cls.get_wordlist(wordlist)
-		return cls.baseNtohex(cls.mn_base,mn,wl,cls._mn2hex_pad(mn))
+		return baseconv.tohex(cls.mn_base,mn,wl,cls._mn2hex_pad(mn))
 
 	@classmethod
 	def get_wordlist(cls,wordlist=None):
@@ -433,9 +410,9 @@ class Mnemonic (SeedSourceUnenc):
 	def _format(self):
 		wl = self.get_wordlist()
 		seed_hex = self.seed.hexdata
-		mn = self.hextobaseN(self.mn_base,seed_hex,wl,self._hex2mn_pad(seed_hex))
+		mn = baseconv.fromhex(self.mn_base,seed_hex,wl,self._hex2mn_pad(seed_hex))
 
-		ret = self.baseNtohex(self.mn_base,mn,wl,self._mn2hex_pad(mn))
+		ret = baseconv.tohex(self.mn_base,mn,wl,self._mn2hex_pad(mn))
 		# Internal error, so just die on fail
 		compare_or_die(ret,'recomputed seed',
 						seed_hex,'original',e='Internal error')
@@ -458,9 +435,9 @@ class Mnemonic (SeedSourceUnenc):
 				msg('Invalid mnemonic: word #%s is not in the wordlist' % n)
 				return False
 
-		seed_hex = self.baseNtohex(self.mn_base,mn,wl,self._mn2hex_pad(mn))
+		seed_hex = baseconv.tohex(self.mn_base,mn,wl,self._mn2hex_pad(mn))
 
-		ret = self.hextobaseN(self.mn_base,seed_hex,wl,self._hex2mn_pad(seed_hex))
+		ret = baseconv.fromhex(self.mn_base,seed_hex,wl,self._hex2mn_pad(seed_hex))
 
 		# Internal error, so just die
 		compare_or_die(' '.join(ret),'recomputed mnemonic',
@@ -503,7 +480,7 @@ class SeedFile (SeedSourceUnenc):
 			msg("'%s': invalid checksum format in %s" % (a, desc))
 			return False
 
-		if not is_b58string(b):
+		if not is_b58_str(b):
 			msg("'%s': not a base 58 string, in %s" % (b, desc))
 			return False
 
@@ -557,7 +534,7 @@ class HexSeedFile (SeedSourceUnenc):
 			msg("'%s': invalid checksum format in %s" % (chk, desc))
 			return False
 
-		if not is_hexstring(hstr):
+		if not is_hex_str(hstr):
 			msg("'%s': not a hexadecimal string, in %s" % (hstr, desc))
 			return False
 

+ 31 - 42
mmgen/tool.py

@@ -33,13 +33,13 @@ from collections import OrderedDict
 cmd_data = OrderedDict([
 	('help',         ['<tool command> [str]']),
 	('usage',        ['<tool command> [str]']),
-	('strtob58',     ['<string> [str-]']),
+	('strtob58',     ['<string> [str-]','pad [int=0]']),
 	('b58tostr',     ['<b58 number> [str-]']),
-	('hextob58',     ['<hex number> [str-]']),
-	('b58tohex',     ['<b58 number> [str-]']),
+	('hextob58',     ['<hex number> [str-]','pad [int=0]']),
+	('b58tohex',     ['<b58 number> [str-]','pad [int=0]']),
 	('b58randenc',   []),
-	('b32tohex',     ['<b32 num> [str-]']),
-	('hextob32',     ['<hex num> [str-]']),
+	('b32tohex',     ['<b32 num> [str-]','pad [int=0]']),
+	('hextob32',     ['<hex num> [str-]','pad [int=0]']),
 	('randhex',      ['nbytes [int=32]']),
 	('id8',          ['<infile> [str]']),
 	('id6',          ['<infile> [str]']),
@@ -80,6 +80,7 @@ cmd_data = OrderedDict([
 	('remove_label',    ['<{} address> [str]'.format(pnm)]),
 	('addrfile_chksum', ['<{} addr file> [str]'.format(pnm)]),
 	('keyaddrfile_chksum', ['<{} addr file> [str]'.format(pnm)]),
+	('passwdfile_chksum', ['<{} password file> [str]'.format(pnm)]),
 	('find_incog_data', ['<file or device name> [str]','<Incog ID> [str]','keep_searching [bool=False]']),
 
 	('encrypt',      ['<infile> [str]',"outfile [str='']","hash_preset [str='']"]),
@@ -137,6 +138,7 @@ cmd_help = """
   remove_label - remove descriptive label for {pnm} address in tracking wallet
   addrfile_chksum    - compute checksum for {pnm} address file
   keyaddrfile_chksum - compute checksum for {pnm} key-address file
+  passwdfile_chksum  - compute checksum for {pnm} password file
   find_incog_data    - Use an Incog ID to find hidden incognito wallet data
   id6          - generate 6-character {pnm} ID for a file (or stdin)
   id8          - generate 8-character {pnm} ID for a file (or stdin)
@@ -213,6 +215,7 @@ def process_args(prog_name, command, cmd_args):
 			tool_usage(prog_name,command)
 
 	def conv_type(arg,arg_name,arg_type):
+		if arg_type == 'str': arg_type = 'unicode'
 		if arg_type == 'bool':
 			if arg.lower() in ('true','yes','1','on'): arg = True
 			elif arg.lower() in ('false','no','0','off'): arg = False
@@ -266,32 +269,6 @@ def unhexdump(infile):
 	sys.stdout.write(decode_pretty_hexdump(
 			get_data_from_file(infile,dash=True,silent=True)))
 
-def strtob58(s):
-	enc = mmb.b58encode(s)
-	dec = mmb.b58decode(enc)
-	print_convert_results(s,enc,dec,'str')
-
-def hextob58(s,f_enc=mmb.b58encode, f_dec=mmb.b58decode):
-	s = s.strip()
-	enc = f_enc(ba.unhexlify(s))
-	dec = ba.hexlify(f_dec(enc))
-	print_convert_results(s,enc,dec,'hex')
-
-def b58tohex(s,f_enc=mmb.b58decode, f_dec=mmb.b58encode):
-	s = s.strip()
-	tmp = f_enc(s)
-	if tmp == False: die(1,"Unable to decode string '%s'" % s)
-	enc = ba.hexlify(tmp)
-	dec = f_dec(ba.unhexlify(enc))
-	print_convert_results(s,enc,dec,'b58')
-
-def b58tostr(s,f_enc=mmb.b58decode, f_dec=mmb.b58encode):
-	s = s.strip()
-	enc = f_enc(s)
-	if enc == False: die(1,"Unable to decode string '%s'" % s)
-	dec = f_dec(enc)
-	print_convert_results(s,enc,dec,'b58')
-
 def b58randenc():
 	r = get_random(32)
 	enc = mmb.b58encode(r)
@@ -331,7 +308,7 @@ def do_random_mn(nbytes,wordlist):
 	Vmsg('Seed: %s' % hexrand)
 	for wlname in ([wordlist],wordlists)[wordlist=='all']:
 		if wordlist == 'all':
-			Msg('%s mnemonic:' % (wlname.capitalize()))
+			Msg('%s mnemonic:' % (capfirst(wlname)))
 		mn = Mnemonic.hex2mn(hexrand,wordlist=wlname)
 		Msg(' '.join(mn))
 
@@ -340,20 +317,28 @@ def mn_rand192(wordlist=dfl_wordlist): do_random_mn(24,wordlist)
 def mn_rand256(wordlist=dfl_wordlist): do_random_mn(32,wordlist)
 
 def hex2mn(s,wordlist=dfl_wordlist):
-	mn = Mnemonic.hex2mn(s,wordlist)
-	Msg(' '.join(mn))
+	Msg(' '.join(Mnemonic.hex2mn(s,wordlist)))
 
 def mn2hex(s,wordlist=dfl_wordlist):
-	hexnum = Mnemonic.mn2hex(s.split(),wordlist)
-	Msg(hexnum)
+	Msg(Mnemonic.mn2hex(s.split(),wordlist))
+
+def strtob58(s,pad=None):
+	Msg(''.join(baseconv.fromhex(58,ba.hexlify(s),mmb.b58a,pad)))
+
+def b58tostr(s):
+	Msg(ba.unhexlify(baseconv.tohex(58,s,mmb.b58a)))
 
-def b32tohex(s):
-	b32a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
-	Msg(Mnemonic.baseNtohex(32,s.upper(),b32a))
+def b58tohex(s,pad=None):
+	Msg(baseconv.tohex(58,s,mmb.b58a,pad))
 
-def hextob32(s):
-	b32a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
-	Msg(''.join(Mnemonic.hextobaseN(32,s,b32a)))
+def hextob58(s,pad=None):
+	Msg(''.join(baseconv.fromhex(58,s,mmb.b58a,pad)))
+
+def b32tohex(s,pad=None):
+	Msg(baseconv.tohex(32,s.upper(),b32a,pad))
+
+def hextob32(s,pad=None):
+	Msg(''.join(baseconv.fromhex(32,s,b32a,pad)))
 
 def mn_stats(wordlist=dfl_wordlist):
 	Mnemonic.check_wordlist(wordlist)
@@ -506,6 +491,10 @@ def keyaddrfile_chksum(infile):
 	from mmgen.addr import KeyAddrList
 	KeyAddrList(infile,chksum_only=True)
 
+def passwdfile_chksum(infile):
+	from mmgen.addr import PasswordList
+	PasswordList(infile=infile,chksum_only=True)
+
 def hexreverse(s):
 	Msg(ba.hexlify(ba.unhexlify(s.strip())[::-1]))
 

+ 1 - 1
mmgen/tw.py

@@ -107,7 +107,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 
 	def sort_info(self,include_group=True):
 		ret = ([],['Reverse'])[self.reverse]
-		ret.append(self.sort_key.capitalize().replace('Mmid','MMGenID'))
+		ret.append(capfirst(self.sort_key).replace('Mmid','MMGenID'))
 		if include_group and self.group and (self.sort_key in ('addr','txid','mmid')):
 			ret.append('Grouped')
 		return ret

+ 3 - 8
mmgen/tx.py

@@ -31,10 +31,6 @@ def is_mmgen_idx(s):     return AddrIdx(s,on_fail='silent')
 def is_mmgen_id(s):      return MMGenID(s,on_fail='silent')
 def is_btc_addr(s):      return BTCAddr(s,on_fail='silent')
 
-def is_b58_str(s):
-	from mmgen.bitcoin import b58a
-	return set(list(s)) <= set(b58a)
-
 def is_wif(s):
 	if s == '': return False
 	from mmgen.bitcoin import wif2hex
@@ -376,7 +372,7 @@ class MMGenTX(MMGenObject):
 			self.view(pager=reply in 'Vv',terse=reply in 'Tt')
 
 	def view(self,pager=False,pause=True,terse=False):
-		o = self.format_view(terse=terse)
+		o = self.format_view(terse=terse).encode('utf8')
 		if pager: do_pager(o)
 		else:
 			sys.stdout.write(o)
@@ -480,9 +476,8 @@ class MMGenTX(MMGenObject):
 			ts = len(self.hex)/2 if self.hex else 'unknown'
 			out += 'Transaction size: estimated - {}, actual - {}\n'.format(self.get_size(),ts)
 
-		# only tx label may contain non-ascii chars
-		# encode() is necessary for test suite with PopenSpawn
-		return out.encode('utf8')
+		# TX label might contain non-ascii chars
+		return out
 
 	def parse_tx_file(self,infile):
 

+ 74 - 57
mmgen/util.py

@@ -26,50 +26,42 @@ from binascii import hexlify,unhexlify
 from string import hexdigits
 from mmgen.color import *
 
-def msg(s):    sys.stderr.write(s+'\n')
-def msg_r(s):  sys.stderr.write(s)
-def Msg(s):    sys.stdout.write(s + '\n')
-def Msg_r(s):  sys.stdout.write(s)
-def msgred(s): sys.stderr.write(red(s+'\n'))
+def msg(s):    sys.stderr.write(s.encode('utf8') + '\n')
+def msg_r(s):  sys.stderr.write(s.encode('utf8'))
+def Msg(s):    sys.stdout.write(s.encode('utf8') + '\n')
+def Msg_r(s):  sys.stdout.write(s.encode('utf8'))
+def msgred(s): msg(red(s))
+
 def mmsg(*args):
-	for d in args:
-		sys.stdout.write(repr(d)+'\n')
+	for d in args: Msg(repr(d))
 def mdie(*args):
-	for d in args:
-		sys.stdout.write(repr(d)+'\n')
-	sys.exit()
+	mmsg(*args); sys.exit()
 
 def die_wait(delay,ev=0,s=''):
 	assert type(delay) == int
 	assert type(ev) == int
-	if s: sys.stderr.write(s+'\n')
+	if s: msg(s)
 	time.sleep(delay)
 	sys.exit(ev)
 def die_pause(ev=0,s=''):
 	assert type(ev) == int
-	if s: sys.stderr.write(s+'\n')
+	if s: msg(s)
 	raw_input('Press ENTER to exit')
 	sys.exit(ev)
 def die(ev=0,s=''):
 	assert type(ev) == int
-	if s: sys.stderr.write(s+'\n')
+	if s: msg(s)
 	sys.exit(ev)
 def Die(ev=0,s=''):
 	assert type(ev) == int
-	if s: sys.stdout.write(s+'\n')
+	if s: Msg(s)
 	sys.exit(ev)
 
 def pp_format(d):
 	import pprint
 	return pprint.PrettyPrinter(indent=4).pformat(d)
-
-def pp_die(d):
-	import pprint
-	die(1,pprint.PrettyPrinter(indent=4).pformat(d))
-
-def pp_msg(d):
-	import pprint
-	msg(pprint.PrettyPrinter(indent=4).pformat(d))
+def pp_die(d): die(1,pp_format(d))
+def pp_msg(d): msg(pp_format(d))
 
 def set_for_type(val,refval,desc,invert_bool=False,src=None):
 	src_str = (''," in '{}'".format(src))[bool(src)]
@@ -120,24 +112,24 @@ def check_or_create_dir(path):
 
 from mmgen.opts import opt
 
-def qmsg(s,alt=False):
+def qmsg(s,alt=None):
 	if opt.quiet:
-		if alt != False: sys.stderr.write(alt + '\n')
-	else: sys.stderr.write(s + '\n')
-def qmsg_r(s,alt=False):
+		if alt != None: msg(alt)
+	else: msg(s)
+def qmsg_r(s,alt=None):
 	if opt.quiet:
-		if alt != False: sys.stderr.write(alt)
-	else: sys.stderr.write(s)
+		if alt != None: msg_r(alt)
+	else: msg_r(s)
 def vmsg(s,force=False):
-	if opt.verbose or force: sys.stderr.write(s + '\n')
+	if opt.verbose or force: msg(s)
 def vmsg_r(s,force=False):
-	if opt.verbose or force: sys.stderr.write(s)
+	if opt.verbose or force: msg_r(s)
 def Vmsg(s,force=False):
-	if opt.verbose or force: sys.stdout.write(s + '\n')
+	if opt.verbose or force: Msg(s)
 def Vmsg_r(s,force=False):
-	if opt.verbose or force: sys.stdout.write(s)
+	if opt.verbose or force: Msg_r(s)
 def dmsg(s):
-	if opt.debug: sys.stdout.write(s + '\n')
+	if opt.debug: msg(s)
 
 def suf(arg,suf_type):
 	t = type(arg)
@@ -170,7 +162,7 @@ def make_chksum_8(s,sep=False):
 	s = sha256(sha256(s).digest()).hexdigest()[:8].upper()
 	return '{} {}'.format(s[:4],s[4:]) if sep else s
 def make_chksum_6(s): return sha256(s).hexdigest()[:6]
-def is_chksum_6(s): return len(s) == 6 and is_hexstring_lc(s)
+def is_chksum_6(s): return len(s) == 6 and is_hex_str_lc(s)
 
 def make_iv_chksum(s): return sha256(s).hexdigest()[:8].upper()
 
@@ -212,35 +204,55 @@ def secs_to_hms(secs):
 def secs_to_ms(secs):
 	return '{:02d}:{:02d}'.format(secs/60, secs % 60)
 
-def _is_whatstring(s,chars):
-	return set(list(s)) <= set(chars)
-
 def is_int(s):
 	try:
-		int(s)
+		int(str(s))
 		return True
 	except:
 		return False
 
-def is_hexstring(s):
-	return _is_whatstring(s.lower(),hexdigits.lower())
-def is_hexstring_lc(s):
-	return _is_whatstring(s,hexdigits.lower())
-def is_hexstring_uc(s):
-	return _is_whatstring(s,hexdigits.upper())
-def is_b58string(s):
+# https://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet
+# https://tools.ietf.org/html/rfc4648
+b32a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
+def is_b32_str(s):    return set(list(s))         <= set(list(b32a))
+def is_hex_str(s):    return set(list(s.lower())) <= set(list(hexdigits.lower()))
+def is_hex_str_lc(s): return set(list(s))         <= set(list(hexdigits.lower()))
+def is_hex_str_uc(s): return set(list(s))         <= set(list(hexdigits.upper()))
+def is_b58_str(s):
 	from mmgen.bitcoin import b58a
-	return _is_whatstring(s,b58a)
-
-def is_utf8(s):
-	try: s.decode('utf8')
-	except: return False
-	else: return True
+	return set(list(s)) <= set(b58a)
 
-def is_ascii(s):
-	try: s.decode('ascii')
+def is_ascii(s,enc='ascii'):
+	try:    s.decode(enc)
 	except: return False
-	else: return True
+	else:   return True
+
+def is_utf8(s): return is_ascii(s,enc='utf8')
+
+class baseconv(object):
+
+	@staticmethod
+	def tohex(base,words,wl,pad=None): # accepts both string and list input
+		if type(words) not in (list,tuple):
+			words = tuple(words.strip())
+		if not set(words).issubset(set(wl)):
+			die(2,'{} is not in base-{} format'.format(repr(words_arg),base))
+		deconv =  [wl.index(words[::-1][i])*(base**i)
+					for i in range(len(words))]
+		ret = ('{:0{w}x}'.format(sum(deconv),w=pad or 0))
+		return ('','0')[len(ret) % 2] + ret
+
+	@staticmethod
+	def fromhex(base,hexnum,wl,pad=None):
+		assert len(wl) == base
+		hexnum = hexnum.strip()
+		if not is_hex_str(hexnum):
+			die(2,"'%s': not a hexadecimal number" % hexnum)
+		num,ret = int(hexnum,16),[]
+		while num:
+			ret.append(num % base)
+			num /= base
+		return [wl[n] for n in [0] * ((pad or 0)-len(ret)) + ret[::-1]]
 
 def match_ext(addr,ext):
 	return addr.split('.')[-1] == ext
@@ -444,6 +456,9 @@ def write_data_to_file(
 	if ask_write_default_yes == False or ask_write_prompt:
 		ask_write = True
 
+	if not binary and type(data) == unicode:
+		data = data.encode('utf8')
+
 	def do_stdout():
 		qmsg('Output to STDOUT requested')
 		if sys.stdout.isatty():
@@ -537,7 +552,7 @@ def get_words(infile,desc,prompt):
 def mmgen_decrypt_file_maybe(fn,desc=''):
 	d = get_data_from_file(fn,desc,binary=True)
 	have_enc_ext = get_extension(fn) == g.mmenc_ext
-	if have_enc_ext or not is_ascii(d):
+	if have_enc_ext or not is_utf8(d):
 		m = ('Attempting to decrypt','Decrypting')[have_enc_ext]
 		msg("%s %s '%s'" % (m,desc,fn))
 		from mmgen.crypto import mmgen_decrypt_retry
@@ -547,7 +562,9 @@ def mmgen_decrypt_file_maybe(fn,desc=''):
 def get_lines_from_file(fn,desc='',trim_comments=False):
 	dec = mmgen_decrypt_file_maybe(fn,desc)
 	ret = dec.decode('utf8').splitlines() # DOS-safe
-	return remove_comments(ret) if trim_comments else ret
+	if trim_comments: ret = remove_comments(ret)
+	vmsg(u"Got {} lines from file '{}'".format(len(ret),fn))
+	return ret
 
 def get_data_from_user(desc='data',silent=False):
 	data = my_raw_input('Enter %s: ' % desc, echo=opt.echo_passphrase)
@@ -593,7 +610,7 @@ def my_raw_input(prompt,echo=True,insert_txt='',use_readline=True):
 	from mmgen.term import kb_hold_protect
 	kb_hold_protect()
 	if echo or not sys.stdin.isatty():
-		reply = raw_input(prompt)
+		reply = raw_input(prompt.encode('utf8'))
 	else:
 		from getpass import getpass
 		reply = getpass(prompt)

+ 41 - 7
setup.py

@@ -16,16 +16,48 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import sys,os,subprocess
+from shutil import copy2
+_gvi = subprocess.check_output(['gcc','--version']).splitlines()[0]
+have_mingw64 = 'x86_64' in _gvi and 'MinGW' in _gvi
+have_arm     = subprocess.check_output(['uname','-m']).strip() == 'aarch64'
+
+# Zipfile module under Windows (MinGW) can't handle UTF-8 filenames.
+# Move it so that distutils will use the 'zip' utility instead.
+def divert_zipfile_module():
+	msg1 = 'Unable to divert zipfile module. UTF-8 filenames may be broken in the Python archive.'
+	def return_warn(m):
+		sys.stderr.write('WARNING: {}\n'.format(m))
+		return False
+
+	dirname = os.path.dirname(sys.modules['os'].__file__)
+	if not dirname: return return_warn(msg1)
+	stem = os.path.join(dirname,'zipfile')
+	a,b = stem+'.py',stem+'-is-broken.py'
+
+	try: os.stat(a)
+	except: return
+
+	try:
+		sys.stderr.write('moving {} -> {}\n'.format(a,b))
+		os.rename(a,b)
+	except:
+		return return_warn(msg1)
+	else:
+		try:
+			os.unlink(stem+'.pyc')
+			os.unlink(stem+'.pyo')
+		except:
+			pass
+
+if have_mingw64:
+# 	import zipfile
+# 	sys.exit()
+	divert_zipfile_module()
+
 from distutils.core import setup,Extension
 from distutils.command.build_ext import build_ext
 from distutils.command.install_data import install_data
-import sys,os
-from shutil import copy2
-
-import subprocess as sp
-_gvi = sp.check_output(['gcc','--version']).splitlines()[0]
-have_mingw64 = 'x86_64' in _gvi and 'MinGW' in _gvi
-have_arm     = sp.check_output(['uname','-m']).strip() == 'aarch64'
 
 # install extension module in repository after building
 class my_build_ext(build_ext):
@@ -100,6 +132,7 @@ setup(
 			'mmgen.main',
 			'mmgen.main_wallet',
 			'mmgen.main_addrgen',
+			'mmgen.main_passgen',
 			'mmgen.main_addrimport',
 			'mmgen.main_txcreate',
 			'mmgen.main_txbump',
@@ -116,6 +149,7 @@ setup(
 		scripts=[
 			'mmgen-addrgen',
 			'mmgen-keygen',
+			'mmgen-passgen',
 			'mmgen-addrimport',
 			'mmgen-passchg',
 			'mmgen-walletchk',

+ 16 - 8
test/gentest.py

@@ -121,8 +121,12 @@ if a and b:
 	gen_a = get_privhex2addr_f(generator=a)
 	gen_b = get_privhex2addr_f(generator=b)
 	compressed = False
-	for i in range(1,rounds+1):
-		qmsg_r('\rRound %s/%s ' % (i,rounds))
+	last_t = time.time()
+
+	for i in range(rounds):
+		if time.time() - last_t >= 0.1:
+			qmsg_r('\rRound %s/%s ' % (i+1,rounds))
+			last_t = time.time()
 		sec = hexlify(os.urandom(32))
 		wif = hex2wif(sec,compressed=compressed)
 		a_addr = gen_a(sec,compressed)
@@ -132,6 +136,7 @@ if a and b:
 			match_error(sec,wif,a_addr,b_addr,a,b)
 		if a != 2 and b != 2:
 			compressed = not compressed
+	qmsg_r('\rRound %s/%s ' % (i+1,rounds))
 
 	qmsg(green(('\n','')[bool(opt.verbose)] + 'OK'))
 elif a and not fh:
@@ -139,24 +144,27 @@ elif a and not fh:
 	qmsg(green(m.format(g.key_generators[a-1])))
 	from mmgen.addr import get_privhex2addr_f
 	gen_a = get_privhex2addr_f(generator=a)
-	import time
-	start = time.time()
 	from struct import pack,unpack
 	seed = os.urandom(28)
 	print 'Incrementing key with each round'
 	print 'Starting key:', hexlify(seed+pack('I',0))
 	compressed = False
+	import time
+	start = last_t = time.time()
+
 	for i in range(rounds):
-		qmsg_r('\rRound %s/%s ' % (i+1,rounds))
+		if time.time() - last_t >= 0.1:
+			qmsg_r('\rRound %s/%s ' % (i+1,rounds))
+			last_t = time.time()
 		sec = hexlify(seed+pack('I',i))
 		wif = hex2wif(sec,compressed=compressed)
 		a_addr = gen_a(sec,compressed)
 		vmsg('\nkey:  %s\naddr: %s\n' % (wif,a_addr))
 		if a != 2:
 			compressed = not compressed
-	elapsed = int(time.time() - start)
-	qmsg('')
-	qmsg('%s addresses generated in %s second%s' % (rounds,elapsed,('s','')[elapsed==1]))
+	qmsg_r('\rRound %s/%s ' % (i+1,rounds))
+
+	qmsg('\n{} addresses generated in {:.2f} seconds'.format(rounds,time.time()-start))
 elif a and dump:
 	m = "Comparing output of address generator '{}' against wallet dump '{}'"
 	qmsg(green(m.format(g.key_generators[a-1],cmd_args[1])))

+ 17 - 0
test/ref/98831F3A-фубар@crypto.org-base58-20[1,4,9-11,1100].pws

@@ -0,0 +1,17 @@
+# MMGen password file
+#
+# This file is editable.
+# Everything following a hash symbol '#' is a comment and ignored by MMGen.
+# A text label of 32 characters or less may be added to the right of each
+# password.  The label may contain any printable ASCII symbol.
+#
+# Password data checksum for 98831F3A-фубар@crypto.org-base58-20[1,4,9-11,1100]: 7723 735B 2CBB 2571
+# Record this value to a secure location.
+98831F3A фубар@crypto.org base58:20 {
+  1     7ds9PiQt1poHpknpQyNg
+  4     Dp4s9nWuzCFsdy39p6tk
+  9     3pPEHJdeF4vid8D7vea4
+  10    iX5q85oD9hnNfg219ztp
+  11    vqgKETaoP8yxVUuHYgkf
+  1100  89i9jmt7s6Nh5PNLdRJH
+}

+ 37 - 8
test/test.py

@@ -280,6 +280,8 @@ cfgs = {
 		'ref_bw_seed_id':  '33F10310',
 		'addrfile_chk':    ('B230 7526 638F 38CB','B64D 7327 EF2A 60FE')[g.testnet],
 		'keyaddrfile_chk': ('CF83 32FB 8A8B 08E2','FEBF 7878 97BB CC35')[g.testnet],
+		'passfile_chk':    '3EA0 A3C9 DA28 5126',
+		'passfile32_chk':  'EF67 D0BE 4B24 9B4F',
 		'wpasswd':         'reference password',
 		'ref_wallet':      'FE3C6545-D782B529[128,1].mmdat',
 		'ic_wallet':       'FE3C6545-E29303EA-5E229E30[128,1].mmincog',
@@ -291,6 +293,7 @@ cfgs = {
 		'tmpdir':        os.path.join('test','tmp6'),
 		'kapasswd':      '',
 		'addr_idx_list': '1010,500-501,31-33,1,33,500,1011', # 8 addresses
+		'pass_idx_list': '1,4,9-11,1100',
 		'dep_generators':  {
 			'mmdat':       'refwalletgen1',
 			pwfile:       'refwalletgen1',
@@ -306,6 +309,8 @@ cfgs = {
 		'ref_bw_seed_id':  'CE918388',
 		'addrfile_chk':    ('8C17 A5FA 0470 6E89','0A59 C8CD 9439 8B81')[g.testnet],
 		'keyaddrfile_chk': ('9648 5132 B98E 3AD9','2F72 C83F 44C5 0FAC')[g.testnet],
+		'passfile_chk':    '000C 7711 CD45 C5BE',
+		'passfile32_chk':  'AFEC 54A1 7D79 1866',
 		'wpasswd':         'reference password',
 		'ref_wallet':      '1378FC64-6F0F9BB4[192,1].mmdat',
 		'ic_wallet':       '1378FC64-2907DE97-F980D21F[192,1].mmincog',
@@ -317,6 +322,7 @@ cfgs = {
 		'tmpdir':        os.path.join('test','tmp7'),
 		'kapasswd':      '',
 		'addr_idx_list': '1010,500-501,31-33,1,33,500,1011', # 8 addresses
+		'pass_idx_list': '1,4,9-11,1100',
 		'dep_generators':  {
 			'mmdat':       'refwalletgen2',
 			pwfile:       'refwalletgen2',
@@ -332,13 +338,16 @@ cfgs = {
 		'ref_bw_seed_id':  'B48CD7FC',
 		'addrfile_chk':    ('6FEF 6FB9 7B13 5D91','3C2C 8558 BB54 079E')[g.testnet],
 		'keyaddrfile_chk': ('9F2D D781 1812 8BAD','7410 8F95 4B33 B4B2')[g.testnet],
+		'passfile_chk':    '54B1 A5BE 9F07 1FDD',
+		'passfile32_chk':  '072A 4A13 FB64 B64B',
 		'wpasswd':         'reference password',
 		'ref_wallet':      '98831F3A-{}[256,1].mmdat'.format(('27F2BF93','E2687906')[g.testnet]),
 		'ref_addrfile':    '98831F3A[1,31-33,500-501,1010-1011]{}.addrs'.format(tn_desc),
 		'ref_keyaddrfile': '98831F3A[1,31-33,500-501,1010-1011]{}.akeys.mmenc'.format(tn_desc),
+		'ref_passwdfile':  '98831F3A-фубар@crypto.org-base58-20[1,4,9-11,1100].pws',
 		'ref_addrfile_chksum':    ('6FEF 6FB9 7B13 5D91','3C2C 8558 BB54 079E')[g.testnet],
 		'ref_keyaddrfile_chksum': ('9F2D D781 1812 8BAD','7410 8F95 4B33 B4B2')[g.testnet],
-
+		'ref_passwdfile_chksum':  '7723 735B 2CBB 2571',
 #		'ref_fake_unspent_data':'98831F3A_unspent.json',
 		'ref_tx_file':     'FFB367[1.234]{}.rawtx'.format(tn_desc),
 		'ic_wallet':       '98831F3A-5482381C-18460FB1[256,1].mmincog',
@@ -350,6 +359,8 @@ cfgs = {
 		'tmpdir':        os.path.join('test','tmp8'),
 		'kapasswd':      '',
 		'addr_idx_list': '1010,500-501,31-33,1,33,500,1011', # 8 addresses
+		'pass_idx_list': '1,4,9-11,1100',
+
 		'dep_generators':  {
 			'mmdat':       'refwalletgen3',
 			pwfile:       'refwalletgen3',
@@ -470,13 +481,16 @@ cmd_group['ref'] = (
 	# generating new reference ('abc' brainwallet) files:
 	('refwalletgen',   ([],'gen new refwallet')),
 	('refaddrgen',     (['mmdat',pwfile],'new refwallet addr chksum')),
-	('refkeyaddrgen',  (['mmdat',pwfile],'new refwallet key-addr chksum'))
+	('refkeyaddrgen',  (['mmdat',pwfile],'new refwallet key-addr chksum')),
+	('refpasswdgen',   (['mmdat',pwfile],'new refwallet passwd file chksum')),
+	('ref_b32passwdgen',(['mmdat',pwfile],'new refwallet passwd file chksum (base32)')),
 )
 
 # misc. saved reference data
 cmd_group['ref_other'] = (
 	('ref_addrfile_chk',   'saved reference address file'),
 	('ref_keyaddrfile_chk','saved reference key-address file'),
+	('ref_passwdfile_chk', 'saved reference password file'),
 #	Create the fake inputs:
 #	('txcreate8',          'transaction creation (8)'),
 	('ref_tx_chk',         'saved reference tx file'),
@@ -1351,17 +1365,20 @@ class MMGenTestSuite(object):
 		have_dfl_wallet = False
 		if not ia: ok()
 
-	def addrgen(self,name,wf,pf=None,check_ref=False):
-		add_args = ([],['-q'] + ([],['-P',pf])[bool(pf)])[ia]
-		t = MMGenExpect(name,'mmgen-addrgen', add_args +
-				['-d',cfg['tmpdir']] + ([],[wf])[bool(wf)] + [cfg['addr_idx_list']])
+	def addrgen(self,name,wf,pf=None,check_ref=False,ftype='addr',id_str=None,extra_args=[]):
+		ftype,chkfile = ((ftype,'{}file_chk'.format(ftype)),('pass','passfile32_chk'))[ftype=='pass32']
+		add_args = extra_args + ([],['-q'] + ([],['-P',pf])[bool(pf)])[ia]
+		dlist = [id_str] if id_str else []
+		t = MMGenExpect(name,'mmgen-{}gen'.format(ftype), add_args +
+				['-d',cfg['tmpdir']] + ([],[wf])[bool(wf)] + dlist + [cfg['{}_idx_list'.format(ftype)]])
 		if ia: return
 		t.license()
 		t.passphrase('MMGen wallet',cfg['wpasswd'])
 		t.expect('Passphrase is OK')
-		chk = t.expect_getend(r'Checksum for address data .*?: ',regex=True)
+		desc = ('address','password')[ftype=='pass']
+		chk = t.expect_getend(r'Checksum for {} data .*?: '.format(desc),regex=True)
 		if check_ref:
-			refcheck('address data checksum',chk,cfg['addrfile_chk'])
+			refcheck('address data checksum',chk,cfg[chkfile])
 			return
 		t.written_to_file('Addresses',oo=True)
 		t.ok()
@@ -1702,6 +1719,13 @@ class MMGenTestSuite(object):
 	def refkeyaddrgen(self,name,wf,pf):
 		self.keyaddrgen(name,wf,pf,check_ref=True)
 
+	def refpasswdgen(self,name,wf,pf):
+		self.addrgen(name,wf,pf,check_ref=True,ftype='pass',id_str='alice@crypto.org')
+
+	def ref_b32passwdgen(self,name,wf,pf):
+		ea = ['--base32','--passwd-len','17']
+		self.addrgen(name,wf,pf,check_ref=True,ftype='pass32',id_str='фубар@crypto.org',extra_args=ea)
+
 	def txsign_keyaddr(self,name,keyaddr_file,txfile):
 		t = MMGenExpect(name,'mmgen-txsign', ['-d',cfg['tmpdir'],'-M',keyaddr_file,txfile])
 		t.license()
@@ -2016,6 +2040,9 @@ class MMGenTestSuite(object):
 	def ref_keyaddrfile_chk(self,name):
 		self.ref_addrfile_chk(name,ftype='keyaddr')
 
+	def ref_passwdfile_chk(self,name):
+		self.ref_addrfile_chk(name,ftype='passwd')
+
 #	def txcreate8(self,name,addrfile):
 #		self.txcreate_common(name,sources=['8'])
 
@@ -2171,6 +2198,8 @@ class MMGenTestSuite(object):
 			'ref_brain_chk',
 			'ref_hincog_chk',
 			'refkeyaddrgen',
+			'refpasswdgen',
+			'ref_b32passwdgen'
 		):
 		for i in ('1','2','3'):
 			locals()[k+i] = locals()[k]

+ 1 - 1
test/tooltest.py

@@ -147,7 +147,7 @@ if opt.list_cmds:
 
 import binascii
 from mmgen.test import *
-from mmgen.tx import is_wif,is_btc_addr,is_b58_str
+from mmgen.tx import is_wif,is_btc_addr
 
 class MMGenToolTestSuite(object):