Browse Source

new mnemonic entry modes, new 'mn2hex_interactive' tool command

Auto-completion functionality for seed phrase entry provides real benefit to the
user, reducing the number of keystrokes required and permitting quick re-entry
of mistyped words.  In addition, unifying the number of keystrokes among words
improves security against acoustic side-channel attacks.  To this end, three
new interactive mnemonic entry modes are introduced by this patch.

Each entry mode is optimized for a particular wordlist.  The “short” mode, for
example, takes advantage of the fact that each word in the Monero wordlist is
uniquely identifiable by its first three letters.  For MMGen’s default Electrum
wordlist, which lacks this unique substring property, the “minimal” mode was
developed to reduce keystrokes to a minimum while retaining the option of
obfuscating entry with pad characters.

Users who prefer not to use auto-completion may specify the “full” mode, which
emulates the previous default behavior.

Overview of the key entry modes:

- 'full' (all wordlists): words are typed in full and entered with the ENTER
  or SPACE key, or by exceeding the pad character limit (see below).

- 'short' (BIP39, Monero): words are entered automatically once user types
  UNIQ_SS_LEN (see below) valid word letters.  3-letter words in the BIP39
  wordlist must be entered with the ENTER or SPACE key, or by exceeding the
  pad character limit.

- 'fixed' (BIP39, Electrum): words are entered automatically once user types
  UNIQ_SS_LEN characters in total.  Words shorter than UNIQ_SS_LEN must be
  padded to fit.  Thus the total number of characters entered is the same for
  all words.

- 'minimal' (Electrum): words are entered automatically once user types the
  minimum number of characters required to uniquely identify a word (varies
  from word to word).  Words that are substrings of other words in the wordlist
  must be entered with the ENTER or SPACE key, or by exceeding the pad
  character limit.  This is the only mode that checks user input letter by
  letter.

Pad character limits by mode:
-----------------------------
  short:   16
  minimal: 16
  full:    longest_word - word_len
  fixed:   uniq_ss_len - word_len

Wordlist parameters:
--------------------
  Parameter        Electrum   BIP39   XMRSEED
  ---------        --------   -----   -------
  uniq_ss_len:     10         4       3
  shortest_word:   3          3       4
  longest word:    12         8       12
  optimum mode:    minimal    fixed   short

Default modes for each wordlist may be configured in 'mmgen.cfg' via the
'mnemonic_entry_modes' option.

Usage / testing:

    $ mmgen-walletconv -i words
    $ mmgen-walletconv -i bip39

    $ mmgen-tool mn2hex_interactive fmt=mmgen mn_len=12 print_mn=1
    $ mmgen-tool mn2hex_interactive fmt=bip39
    $ mmgen-tool mn2hex_interactive fmt=xmrseed

    $ test/unit_tests.py mn_entry
    $ test/test.py -e input
The MMGen Project 5 years ago
parent
commit
04add0df

+ 2 - 0
README.md

@@ -113,6 +113,8 @@ standard.
 - **Wallet-free operation:** All wallet operations can be performed directly
   from your seed phrase at the prompt, allowing you to dispense with a
   physically stored wallet entirely if you wish.
+- Word-completing **mnemonic entry modes** customized for each of MMGen’s
+  supported wordlists minimize keystrokes during seed phrase entry.
 - **Stealth mnemonic entry:** This feature allows you to obfuscate your seed
   phrase with “dead” keystrokes to guard against acoustic side-channel attacks.
 - **Network privacy:** MMGen never “calls home” or checks for upgrades over the

+ 4 - 0
data_files/mmgen.cfg

@@ -62,6 +62,10 @@
 # Set the maximum input size - applies both to files and standard input:
 # max_input_size 1048576
 
+# Set the mnemonic entry mode for each supported wordlist.  Setting this option
+# also turns off all information output for the configured wordlists:
+# mnemonic_entry_modes mmgen:minimal bip39:fixed xmrseed:short
+
 
 #####################
 ## Altcoin options ##

+ 3 - 1
mmgen/globalvars.py

@@ -115,6 +115,8 @@ class g(object):
 	test_suite_popen_spawn = False
 	terminal_width       = 0
 
+	mnemonic_entry_modes = {}
+
 	for k in ('linux','win','msys'):
 		if sys.platform[:len(k)] == k:
 			platform = { 'linux':'linux', 'win':'win', 'msys':'win' }[k]
@@ -174,7 +176,7 @@ class g(object):
 		'color','debug','hash_preset','http_timeout','no_license','rpc_host','rpc_port',
 		'quiet','tx_fee_adj','usr_randchars','testnet','rpc_user','rpc_password',
 		'monero_wallet_rpc_host','monero_wallet_rpc_user','monero_wallet_rpc_password',
-		'daemon_data_dir','force_256_color','regtest','subseeds',
+		'daemon_data_dir','force_256_color','regtest','subseeds','mnemonic_entry_modes',
 		'btc_max_tx_fee','ltc_max_tx_fee','bch_max_tx_fee','eth_max_tx_fee',
 		'eth_mainnet_chain_name','eth_testnet_chain_name',
 		'max_tx_file_size','max_input_size'

+ 419 - 0
mmgen/mn_entry.py

@@ -0,0 +1,419 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2020 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/>.
+
+"""
+mn_entry.py - Mnemonic user entry methods for the MMGen suite
+"""
+
+import time
+
+from mmgen.globalvars import *
+from mmgen.util import msg,msg_r,qmsg,fmt,fmt_list,capfirst,die
+from mmgen.term import get_char,get_char_raw
+from mmgen.color import cyan
+
+from string import ascii_lowercase as _word_chars
+_return_chars = '\n\r '
+_erase_chars = '\b\x7f'
+
+class MnEntryMode(object):
+	"""
+	Subclasses must implement:
+	  - pad_max:    pad character limit (None if variable)
+	  - ss_len:     substring length for automatic entry
+	  - get_word(): get a word from the user and return an index into the wordlist,
+	                or None on failure
+	"""
+
+	pad_max_info = """
+		Up to {pad_max}
+		pad characters per word are permitted.
+	"""
+
+	def __init__(self,mne):
+		self.pad_max_info = '  ' + self.pad_max_info.lstrip() if self.pad_max else '\n'
+		self.mne = mne
+
+	def get_char(self,s):
+		did_erase = False
+		while True:
+			ch = get_char_raw('',num_chars=1).decode()
+			if s and ch in _erase_chars:
+				s = s[:-1]
+				did_erase = True
+			else:
+				return (ch,s,did_erase)
+
+class MnEntryModeFull(MnEntryMode):
+	name = 'Full'
+	choose_info = """
+		Words must be typed in full and entered with ENTER, SPACE,
+		or a pad character.
+	"""
+	prompt_info = """
+		Use the ENTER or SPACE key to enter each word.  A pad character will also
+		enter a word once you’ve typed {ssl} characters total (including pad chars).
+	"""
+	pad_max = None
+
+	@property
+	def ss_len(self):
+		return self.mne.longest_word
+
+	def get_word(self,mne):
+		s,pad = ('', 0)
+		while True:
+			ch,s,foo = self.get_char(s)
+			if ch in _return_chars:
+				if s:
+					break
+			elif ch in _word_chars:
+				s += ch
+			else:
+				pad += 1
+				if pad + len(s) > self.ss_len:
+					break
+
+		return mne.idx(s,'full')
+
+class MnEntryModeShort(MnEntryMode):
+	name = 'Short'
+	choose_info = """
+		Words are entered automatically once {usl} valid word letters
+		are typed.
+	"""
+	prompt_info = """
+		Each word is entered automatically once {ssl} valid word letters are typed.
+	"""
+	prompt_info_bip39_add = """
+		Words shorter than {ssl} letters can be entered with ENTER or SPACE, or by
+		exceeding the pad character limit.
+	"""
+	pad_max = 16
+
+	def __init__(self,mne):
+		if mne.wl_id == 'bip39':
+			self.prompt_info += '  ' + self.prompt_info_bip39_add.strip()
+		return super().__init__(mne)
+
+	@property
+	def ss_len(self):
+		return self.mne.uniq_ss_len
+
+	def get_word(self,mne):
+		s,pad = ('', 0)
+		while True:
+			ch,s,foo = self.get_char(s)
+			if ch in _return_chars:
+				if s:
+					break
+			elif ch in _word_chars:
+				s += ch
+				if len(s) == self.ss_len:
+					break
+			else:
+				pad += 1
+				if pad > self.pad_max:
+					break
+
+		return mne.idx(s,'short')
+
+class MnEntryModeFixed(MnEntryMode):
+	name = 'Fixed'
+	choose_info = """
+		Words are entered automatically once exactly {usl} characters
+		are typed.
+	"""
+	prompt_info = """
+		Each word is entered automatically once exactly {ssl} characters are typed.
+	"""
+	prompt_info_add = ( """
+		Words shorter than {ssl} letters must be padded to fit.
+		""", """
+		{sw}-letter words must be padded with one pad character.
+		""" )
+	pad_max = None
+
+	def __init__(self,mne):
+		self.len_diff = mne.uniq_ss_len - mne.shortest_word
+		self.prompt_info += self.prompt_info_add[self.len_diff==1].lstrip()
+		return super().__init__(mne)
+
+	@property
+	def ss_len(self):
+		return self.mne.uniq_ss_len
+
+	def get_word(self,mne):
+		s,pad = ('', 0)
+		while True:
+			ch,s,foo = self.get_char(s)
+			if ch in _return_chars:
+				if s:
+					break
+			elif ch in _word_chars:
+				s += ch
+				if len(s) + pad == self.ss_len:
+					return mne.idx(s,'short')
+			else:
+				pad += 1
+				if pad > self.len_diff:
+					return None
+				if len(s) + pad == self.ss_len:
+					return mne.idx(s,'short')
+
+class MnEntryModeMinimal(MnEntryMode):
+	name = 'Minimal'
+	choose_info = """
+		Words are entered automatically once a minimum number of
+		letters are typed (the number varies from word to word).
+	"""
+	prompt_info = """
+		Each word is entered automatically once the minimum required number of valid
+		word letters is typed.
+
+		If your word is not entered automatically, that means it’s a substring of
+		another word in the wordlist.  Such words must be entered explicitly with
+		the ENTER or SPACE key, or by exceeding the pad character limit.
+	"""
+	pad_max = 16
+	ss_len = None
+
+	def get_word(self,mne):
+		s,pad = ('', 0)
+		lo,hi = (0, len(mne.wl) - 1)
+		while True:
+			ch,s,did_erase = self.get_char(s)
+			if did_erase:
+				lo,hi = (0, len(mne.wl) - 1)
+			if ch in _return_chars:
+				if s:
+					return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
+			elif ch in _word_chars:
+				s += ch
+				ret = mne.idx(s,'minimal',lo_idx=lo,hi_idx=hi)
+				if type(ret) != tuple:
+					return ret
+				lo,hi = ret
+			else:
+				pad += 1
+				if pad > self.pad_max:
+					return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
+
+def mn_entry(wl_id,entry_mode=None):
+	if wl_id == 'words':
+		wl_id = 'mmgen'
+	me = MnemonicEntry.get_cls_by_wordlist(wl_id)
+	import importlib
+	me.conv_cls = getattr(importlib.import_module('mmgen.{}'.format(me.modname)),me.modname)
+	me.conv_cls.init_mn(wl_id)
+	me.wl = me.conv_cls.digits[wl_id]
+	obj = me()
+	if entry_mode:
+		obj.em = globals()['MnEntryMode'+capfirst(entry_mode)](obj)
+	return obj
+
+class MnemonicEntry(object):
+
+	prompt_info = {
+		'intro': """
+			You will now be prompted for your {ml}-word seed phrase, one word at a time.
+		""",
+		'pad_info': """
+			Note that anything you type that’s not a lowercase letter will simply be
+			ignored.  This feature allows you to guard against acoustic side-channel
+			attacks by padding your keyboard entry with “dead characters”.  Pad char-
+			acters may be typed before, after, or in the middle of words.
+		""",
+	}
+	word_prompt = ('Enter word #{}: ','Incorrect entry. Repeat word #{}: ')
+	dfl_entry_mode = None
+	_lw = None
+	_sw = None
+	_usl = None
+
+	@property
+	def longest_word(self):
+		if not self._lw:
+			self._lw = max(len(w) for w in self.wl)
+		return self._lw
+
+	@property
+	def shortest_word(self):
+		if not self._sw:
+			self._sw = min(len(w) for w in self.wl)
+		return self._sw
+
+	@property
+	def uniq_ss_len(self):
+		if not self._usl:
+			usl = 0
+			for i in range(len(self.wl)-1):
+				w1,w2 = self.wl[i],self.wl[i+1]
+				while True:
+					if w1[:usl] == w2[:usl]:
+						usl += 1
+					else:
+						break
+			self._usl = usl
+		return self._usl
+
+	def idx(self,w,entry_mode,lo_idx=None,hi_idx=None):
+		"""
+		Return values:
+		  - all modes:
+		    - None:            failure (substr not in list)
+		    - idx:             success
+		  - minimal mode:
+		    - (lo_idx,hi_idx): non-unique match
+		"""
+		trunc_len = {
+			'full': self.longest_word,
+			'short': self.uniq_ss_len,
+			'minimal': len(w),
+		}[entry_mode]
+		w = w[:trunc_len]
+		last_idx = len(self.wl) - 1
+		lo = lo_idx or 0
+		hi = hi_idx or last_idx
+		while True:
+			idx = (hi + lo) // 2
+			cur_w = self.wl[idx][:trunc_len]
+			if cur_w == w:
+				if entry_mode == 'minimal':
+					if idx > 0 and self.wl[idx-1][:len(w)] == w:
+						return (lo,hi)
+					elif idx < last_idx and self.wl[idx+1][:len(w)] == w:
+						return (lo,hi)
+				return idx
+			elif hi <= lo:
+				return None
+			elif cur_w > w:
+				hi = idx - 1
+			else:
+				lo = idx + 1
+
+	def get_cls_by_entry_mode(self,entry_mode):
+		return globals()['MnEntryMode'+capfirst(entry_mode)]
+
+	def choose_entry_mode(self):
+		msg('Choose an entry mode:\n')
+		em_objs = [self.get_cls_by_entry_mode(entry_mode)(self) for entry_mode in self.entry_modes]
+		for n,mode in enumerate(em_objs,1):
+			msg('  {}) {:8} {}'.format(
+				n,
+				mode.name + ':',
+				fmt(mode.choose_info,' '*14).lstrip().format(usl=self.uniq_ss_len),
+			))
+		while True:
+			uret = get_char('Entry mode: ').decode()
+			if uret in [str(i) for i in range(1,len(em_objs)+1)]:
+				return em_objs[int(uret)-1]
+			else:
+				msg_r('\b {!r}: invalid choice '.format(uret))
+				time.sleep(g.err_disp_timeout)
+				msg_r('\r'+' '*38+'\r')
+
+	def get_mnemonic_from_user(self,mn_len,validate=True):
+		mll = list(self.conv_cls.seedlen_map_rev[self.wl_id])
+		assert mn_len in mll, '{}: invalid mnemonic length (must be one of {})'.format(mn_len,mll)
+
+		if self.dfl_entry_mode:
+			em = self.get_cls_by_entry_mode(self.dfl_entry_mode)(self)
+			i_add = ' (user-configured)'
+		else:
+			em = self.choose_entry_mode()
+			i_add = '.'
+
+		msg('\r' + 'Using {} entry mode{}'.format(cyan(em.name.upper()),i_add))
+		self.em = em
+
+		if not self.dfl_entry_mode:
+			m = (
+				fmt(self.prompt_info['intro'])
+				+ '\n'
+				+ fmt(self.prompt_info['pad_info'].rstrip() + em.pad_max_info + em.prompt_info, indent='  ')
+			)
+			msg('\n' + m.format(
+				ml       = mn_len,
+				ssl      = em.ss_len,
+				pad_max  = em.pad_max,
+				sw       = self.shortest_word,
+			))
+
+		clear_line = '\n' if g.test_suite else '{r}{s}{r}'.format(r='\r',s=' '*40)
+		idx,idxs = 1,[] # initialize idx to a non-None value
+
+		while len(idxs) < mn_len:
+			msg_r(self.word_prompt[idx is None].format(len(idxs)+1))
+			idx = em.get_word(self)
+			msg_r(clear_line)
+			if idx is None:
+				time.sleep(0.1)
+			else:
+				idxs.append(idx)
+
+		words = [self.wl[i] for i in idxs]
+
+		if validate:
+			self.conv_cls.tohex(words,self.wl_id)
+			qmsg('Mnemonic is valid')
+
+		return ' '.join(words)
+
+	@classmethod
+	def get_cls_by_wordlist(cls,wl):
+		d = {
+			'mmgen': MnemonicEntryMMGen,
+			'bip39': MnemonicEntryBIP39,
+			'xmrseed': MnemonicEntryMonero,
+		}
+		wl = wl.lower()
+		if wl not in d:
+			m = 'wordlist {!r} not recognized (valid options: {})'
+			raise ValueError(m.format(wl,fmt_list(list(d))))
+		return d[wl]
+
+	@classmethod
+	def get_cfg_vars(cls):
+		for k,v in g.mnemonic_entry_modes.items():
+			tcls = cls.get_cls_by_wordlist(k)
+			if v not in tcls.entry_modes:
+				m = 'entry mode {!r} not recognized for wordlist {!r}:\n    (valid options: {})'
+				raise ValueError(m.format(v,k,fmt_list(tcls.entry_modes)))
+			tcls.dfl_entry_mode = v
+
+class MnemonicEntryMMGen(MnemonicEntry):
+	wl_id = 'mmgen'
+	modname = 'baseconv'
+	entry_modes = ('full','minimal','fixed')
+
+class MnemonicEntryBIP39(MnemonicEntry):
+	wl_id = 'bip39'
+	modname = 'bip39'
+	entry_modes = ('full','short','fixed')
+
+class MnemonicEntryMonero(MnemonicEntry):
+	wl_id = 'xmrseed'
+	modname = 'baseconv'
+	entry_modes = ('full','short')
+
+try:
+	MnemonicEntry.get_cfg_vars()
+except Exception as e:
+	m = "Error in cfg file option 'mnemonic_entry_modes':\n  {}"
+	die(2,m.format(e.args[0]))

+ 2 - 49
mmgen/seed.py

@@ -856,56 +856,9 @@ class MMGenMnemonic(SeedSourceUnenc):
 		if not g.stdin_tty:
 			return get_data_from_user(desc)
 
+		from mmgen.mn_entry import mn_entry # import here to catch cfg var errors
 		mn_len = self._choose_seedlen(self.wclass,self.mn_lens,self.mn_type)
-
-		self.conv_cls.init_mn(self.wl_id)
-		wl = self.conv_cls.digits[self.wl_id]
-		longest_word = max(len(w) for w in wl)
-
-		m  = 'Enter your {ml}-word seed phrase, hitting ENTER or SPACE after each word.\n'
-		m += "Optionally, you may use pad characters.  Anything you type that's not a\n"
-		m += 'lowercase letter will be treated as a “pad character”, i.e. it will simply\n'
-		m += 'be discarded.  Pad characters may be typed before, after, or in the middle\n'
-		m += "of words.  For each word, once you've typed {lw} characters total (including\n"
-		m += 'pad characters) any pad character will enter the word.'
-
-		msg(m.format(ml=mn_len,lw=longest_word))
-
-		from string import ascii_lowercase
-		from mmgen.term import get_char_raw
-		def get_word():
-			s,pad = '',0
-			while True:
-				ch = get_char_raw('',num_chars=1).decode()
-				if ch in '\b\x7f':
-					if s: s = s[:-1]
-				elif ch in '\n\r ':
-					if s: break
-				elif ch not in ascii_lowercase:
-					pad += 1
-					if s and pad + len(s) > longest_word:
-						break
-				else:
-					s += ch
-			return s
-
-		def in_list(w):
-			from bisect import bisect_left
-			idx = bisect_left(wl,w)
-			return(True,False)[idx == len(wl) or w != wl[idx]]
-
-		p = ('Enter word #{}: ','Incorrect entry. Repeat word #{}: ')
-		words,err = [],0
-		while len(words) < mn_len:
-			msg_r('{r}{s}{r}'.format(r='\r',s=' '*40))
-			if err == 1: time.sleep(0.1)
-			msg_r(p[err].format(len(words)+1))
-			s = get_word()
-			if in_list(s): words.append(s)
-			err = (1,0)[in_list(s)]
-		msg('')
-		qmsg('Mnemonic successfully entered')
-		return ' '.join(words)
+		return mn_entry(self.wl_id).get_mnemonic_from_user(mn_len)
 
 	@staticmethod
 	def _mn2hex_pad(mn): return len(mn) * 8 // 3

+ 8 - 0
mmgen/tool.py

@@ -547,6 +547,14 @@ class MMGenToolCmdMnemonic(MMGenToolCmdBase):
 		else:
 			return baseconv.tohex(seed_mnemonic.split(),fmt,'seed')
 
+	def mn2hex_interactive( self, fmt:mn_opts_disp=dfl_mnemonic_fmt, mn_len=24, print_mn=False ):
+		"convert an interactively supplied mnemonic seed phrase to a hexadecimal number"
+		from mmgen.mn_entry import mn_entry
+		mn = mn_entry(fmt).get_mnemonic_from_user(25 if fmt == 'xmrseed' else mn_len,validate=False)
+		if print_mn:
+			msg(mn)
+		return self.mn2hex(seed_mnemonic=mn,fmt=fmt)
+
 	def mn_stats(self, fmt:mn_opts_disp = dfl_mnemonic_fmt ):
 		"show stats for mnemonic wordlist"
 		conv_cls = mnemonic_fmts[fmt]['conv_cls']()

+ 1 - 0
setup.py

@@ -112,6 +112,7 @@ setup(
 			'mmgen.globalvars',
 			'mmgen.keccak',
 			'mmgen.license',
+			'mmgen.mn_entry',
 			'mmgen.mn_electrum',
 			'mmgen.mn_monero',
 			'mmgen.mn_tirosh',

+ 14 - 0
test/common.py

@@ -44,6 +44,20 @@ text_jp = '必要なのは、信用ではなく暗号化された証明に基づ
 text_zh = '所以,我們非常需要這樣一種電子支付系統,它基於密碼學原理而不基於信用,使得任何達成一致的雙方,能夠直接進行支付,從而不需要協力廠商仲介的參與。。' # 72 chars ('F'ull + 'W'ide)
 
 sample_text = 'The Times 03/Jan/2009 Chancellor on brink of second bailout for banks'
+sample_mn = {
+	'mmgen': { # 'able': 0, 'youth': 1625, 'after' == 'afternoon'[:5]
+		'mn': 'able cast forgive master funny gaze after afternoon million paint moral youth',
+		'hex': '0005685ab4e94cbe3b228cf92112bc5f',
+	},
+	'bip39': { # len('sun') < uniq_ss_len
+		'mn': 'vessel ladder alter error federal sibling chat ability sun glass valve picture',
+		'hex': 'f30f8c1da665478f49b001d94c5fc452',
+	},
+	'xmrseed': {
+		'mn': 'viewpoint donuts ardent template unveil agile meant unafraid urgent athlete rustled mime azure jaded hawk baby jagged haystack baby jagged haystack ramped oncoming point template',
+		'hex': 'e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f',
+	},
+}
 
 ref_kafile_pass = 'kafile password'
 ref_kafile_hash_preset = '1'

+ 1 - 1
test/ref/mmgen.cfg

@@ -64,7 +64,7 @@
 
 # Set the mnemonic entry mode for each supported wordlist.  Setting this option
 # also turns off all information output for the configured wordlists:
-# mnemonic_entry_modes mmgen:minimal bip39:fixed xmrseed:short
+# mnemonic_entry_modes mmgen:minimal bip39:fixed xmrseed:short 
 
 
 #####################

+ 26 - 8
test/test_py_d/common.py

@@ -146,7 +146,7 @@ def get_label(do_shuffle=False):
 		label_iter = iter(labels)
 		return next(label_iter)
 
-def stealth_mnemonic_entry(t,mn,fmt,pad_entry=False):
+def stealth_mnemonic_entry(t,mne,mn,entry_mode,pad_entry=False):
 
 	def pad_mnemonic(mn,ss_len):
 		def get_pad_chars(n):
@@ -157,23 +157,41 @@ def stealth_mnemonic_entry(t,mn,fmt,pad_entry=False):
 			return ret
 		ret = []
 		for w in mn:
-			if len(w) > (3,5)[ss_len==12]:
+			if entry_mode == 'short':
+				w = w[:ss_len]
+				if len(w) < ss_len:
+					npc = 3
+					w = w[0] + get_pad_chars(npc) + w[1:]
+					if pad_entry:
+						w += '%' * (1 + mne.em.pad_max - npc)
+					else:
+						w += '\n'
+				else:
+					w = get_pad_chars(1) + w[0] + get_pad_chars(1) + w[1:]
+			elif len(w) > (3,5)[ss_len==12]:
 				w = w + '\n'
 			else:
 				w = (
-					get_pad_chars(2 if randbool() else 0)
+					get_pad_chars(2 if randbool() and entry_mode != 'short' else 0)
 					+ w[0] + get_pad_chars(2) + w[1:]
 					+ get_pad_chars(9) )
 				w = w[:ss_len+1]
 			ret.append(w)
 		return ret
 
-	mn = ['fzr'] + mn[:5] + ['grd','grdbxm'] + mn[5:]
-	mn = pad_mnemonic(mn,(12,8)[fmt=='bip39'])
-
-	wnum,em,rm = 1,'Enter word #{}: ','Repeat word #{}: '
+	if entry_mode == 'fixed':
+		mn = ['bkr'] + mn[:5] + ['nfb'] + mn[5:]
+		ssl = mne.uniq_ss_len
+		mn = [w[:ssl] if len(w) >= ssl else (w[0] + 'z\b{}'.format('#'*(ssl-len(w))) + w[1:]) for w in mn]
+	elif entry_mode in ('full','short'):
+		mn = ['fzr'] + mn[:5] + ['grd','grdbxm'] + mn[5:]
+		mn = pad_mnemonic(mn,mne.em.ss_len)
+		mn[10] = '@#$%*##' + mn[10]
+
+	wnum = 1
+	p_ok,p_err = mne.word_prompt
 	for w in mn:
-		ret = t.expect((em.format(wnum),rm.format(wnum-1)))
+		ret = t.expect((p_ok.format(wnum),p_err.format(wnum-1)))
 		if ret == 0:
 			wnum += 1
 		for j in range(len(w)):

+ 5 - 1
test/test_py_d/ts_autosign.py

@@ -70,7 +70,11 @@ class TestSuiteAutosign(TestSuiteBase):
 			t.expect('OK? (Y/n): ','\n')
 			mn_file = dfl_words_file
 			mn = read_from_file(mn_file).strip().split()
-			stealth_mnemonic_entry(t,mn,fmt='words')
+			from mmgen.mn_entry import mn_entry
+			entry_mode = 'full'
+			mne = mn_entry('mmgen',entry_mode)
+			t.expect('Entry mode: ',str(mne.entry_modes.index(entry_mode)+1))
+			stealth_mnemonic_entry(t,mne,mn,entry_mode)
 			wf = t.written_to_file('Autosign wallet')
 			t.ok()
 

+ 64 - 2
test/test_py_d/ts_input.py

@@ -22,7 +22,16 @@ class TestSuiteInput(TestSuiteBase):
 		('password_entry_noecho',         (1,"utf8 password entry", [])),
 		('password_entry_echo',           (1,"utf8 password entry (echoed)", [])),
 		('mnemonic_entry_mmgen',          (1,"stealth mnemonic entry (mmgen)", [])),
+		('mnemonic_entry_mmgen_minimal',  (1,"stealth mnemonic entry (mmgen - minimal entry mode)", [])),
 		('mnemonic_entry_bip39',          (1,"stealth mnemonic entry (bip39)", [])),
+		('mnemonic_entry_bip39_short',    (1,"stealth mnemonic entry (bip39 - short entry mode)", [])),
+		('mn2hex_interactive_mmgen',      (1,"mn2hex_interactive (mmgen)", [])),
+		('mn2hex_interactive_mmgen_fixed',(1,"mn2hex_interactive (mmgen - fixed (10-letter) entry mode)", [])),
+		('mn2hex_interactive_bip39',      (1,"mn2hex_interactive (bip39)", [])),
+		('mn2hex_interactive_bip39_short',(1,"mn2hex_interactive (bip39 - short entry mode (+pad entry))", [])),
+		('mn2hex_interactive_bip39_fixed',(1,"mn2hex_interactive (bip39 - fixed (4-letter) entry mode)", [])),
+		('mn2hex_interactive_xmr',        (1,"mn2hex_interactive (xmrseed)", [])),
+		('mn2hex_interactive_xmr_short',  (1,"mn2hex_interactive (xmrseed - short entry mode)", [])),
 		('dieroll_entry',                 (1,"dieroll entry (base6d)", [])),
 		('dieroll_entry_usrrand',         (1,"dieroll entry (base6d) with added user entropy", [])),
 	)
@@ -51,7 +60,21 @@ class TestSuiteInput(TestSuiteBase):
 			return ('skip_warn',m)
 		return self.password_entry('Enter passphrase (echoed): ',['--echo-passphrase'])
 
-	def _user_seed_entry(self,fmt,usr_rand=False,out_fmt=None,mn=None):
+	def _mn2hex(self,fmt,entry_mode='full',mn=None,pad_entry=False):
+		mn = mn or sample_mn[fmt]['mn'].split()
+		t = self.spawn('mmgen-tool',['mn2hex_interactive','fmt='+fmt,'mn_len=12','print_mn=1'])
+		from mmgen.mn_entry import mn_entry
+		mne = mn_entry(fmt,entry_mode)
+		t.expect('Entry mode: ',str(mne.entry_modes.index(entry_mode)+1))
+		t.expect('Using (.+) entry mode',regex=True)
+		mode = t.p.match.group(1).lower()
+		assert mode == mne.em.name.lower(), '{} != {}'.format(mode,mne.em.name.lower())
+		stealth_mnemonic_entry(t,mne,mn,entry_mode=entry_mode,pad_entry=pad_entry)
+		t.expect(sample_mn[fmt]['hex'])
+		t.read()
+		return t
+
+	def _user_seed_entry(self,fmt,usr_rand=False,out_fmt=None,entry_mode='full',mn=None):
 		wcls = SeedSource.fmt_code_to_type(fmt)
 		wf = os.path.join(ref_dir,'FE3C6545.{}'.format(wcls.ext))
 		if wcls.wclass == 'mnemonic':
@@ -65,7 +88,15 @@ class TestSuiteInput(TestSuiteBase):
 		t.expect(wcls.choose_seedlen_prompt,'1')
 		t.expect('(Y/n): ','y')
 		if wcls.wclass == 'mnemonic':
-			stealth_mnemonic_entry(t,mn,fmt=fmt)
+			t.expect('Entry mode: ','6')
+			t.expect('invalid')
+			from mmgen.mn_entry import mn_entry
+			mne = mn_entry(fmt,entry_mode)
+			t.expect('Entry mode: ',str(mne.entry_modes.index(entry_mode)+1))
+			t.expect('Using (.+) entry mode',regex=True)
+			mode = t.p.match.group(1).lower()
+			assert mode == mne.em.name.lower(), '{} != {}'.format(mode,mne.em.name.lower())
+			stealth_mnemonic_entry(t,mne,mn,entry_mode=entry_mode)
 		elif wcls.wclass == 'dieroll':
 			user_dieroll_entry(t,mn)
 			if usr_rand:
@@ -81,8 +112,39 @@ class TestSuiteInput(TestSuiteBase):
 		t.read()
 		return t
 
+	def mnemonic_entry_mmgen_minimal(self):
+		from mmgen.mn_entry import mn_entry
+		# erase_chars: '\b\x7f'
+		m = mn_entry('mmgen','minimal')
+		np = 2
+		mn = (
+			'z',
+			'aa',
+			'1d2ud',
+			'fo{}ot{}#'.format('1' * np, '2' * (m.em.pad_max - np)), # substring of 'football'
+			'des1p)%erate\n', # substring of 'desperately'
+			'#t!(ie',
+			'!)sto8o',
+			'the123m8!%s',
+			'349t(5)rip',
+			'di\b\bdesce',
+			'cea',
+			'bu\x7f\x7fsuic',
+			'app\bpl',
+			'wd',
+			'busy')
+		return self._user_seed_entry('words',entry_mode='minimal',mn=mn)
 	def mnemonic_entry_mmgen(self):           return self._user_seed_entry('words',entry_mode='full')
 	def mnemonic_entry_bip39(self):           return self._user_seed_entry('bip39',entry_mode='full')
+	def mnemonic_entry_bip39_short(self):     return self._user_seed_entry('bip39',entry_mode='short')
+
+	def mn2hex_interactive_mmgen(self):       return self._mn2hex('mmgen',entry_mode='full')
+	def mn2hex_interactive_mmgen_fixed(self): return self._mn2hex('mmgen',entry_mode='fixed')
+	def mn2hex_interactive_bip39(self):       return self._mn2hex('bip39',entry_mode='full')
+	def mn2hex_interactive_bip39_short(self): return self._mn2hex('bip39',entry_mode='short',pad_entry=True)
+	def mn2hex_interactive_bip39_fixed(self): return self._mn2hex('bip39',entry_mode='fixed')
+	def mn2hex_interactive_xmr(self):         return self._mn2hex('xmrseed',entry_mode='full')
+	def mn2hex_interactive_xmr_short(self):   return self._mn2hex('xmrseed',entry_mode='short')
 
 	def dieroll_entry(self):         return self._user_seed_entry('dieroll')
 	def dieroll_entry_usrrand(self): return self._user_seed_entry('dieroll',usr_rand=True,out_fmt='bip39')

+ 82 - 0
test/unit_tests_d/ut_mn_entry.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+test/unit_tests_d/ut_mn_entry.py: Mnemonic user entry unit test for the MMGen suite
+"""
+
+from mmgen.util import msg,msg_r,qmsg,qmsg_r
+
+class unit_test(object):
+
+	vectors = {
+		'mmgen':   {
+			'usl': 10, 'sw': 3, 'lw': 12,
+			'idx_minimal': ( # None: non-unique match. False: no match
+				('a',         None),
+				('aa',        False),
+				('as',        None),
+				('ask',       70),
+				('afte',      None),
+				('after',     None),
+				('aftern',    20),
+				('afternoon', 20),
+				('afternoons',False),
+				('g',         None),
+				('gg',        False),
+				('z',         False),
+				('abi',       False),
+				('abo',       None),
+				('abl',       0),
+				('able',      0),
+				('abler',     False),
+				('you',       None),
+				('yout',      1625),
+				('youth',     1625),
+				('youths',    False),
+			),
+		},
+		'xmrseed': { 'usl': 3, 'sw': 4, 'lw': 12 },
+		'bip39':   { 'usl': 4, 'sw': 3, 'lw': 8 },
+	}
+
+	def run_test(self,name,ut):
+
+		msg_r('Testing MnemonicEntry methods...')
+
+		from mmgen.mn_entry import mn_entry
+
+		msg_r('\nTesting computed wordlist constants...')
+		usl = {}
+		for wl_id in self.vectors:
+			for j,k in (('uniq_ss_len','usl'),('shortest_word','sw'),('longest_word','lw')):
+				a = getattr(mn_entry(wl_id),j)
+				b = self.vectors[wl_id][k]
+				assert a == b, '{}:{} {} != {}'.format(wl_id,j,a,b)
+		msg('OK')
+
+		msg_r('Testing idx()...')
+		qmsg('')
+		junk = 'a g z aa gg zz aaa ggg zzz aaaa gggg zzzz aaaaaaaaaaaaaa gggggggggggggg zzzzzzzzzzzzzz'
+		for wl_id in self.vectors:
+			m = mn_entry(wl_id)
+			qmsg('Wordlist: '+wl_id)
+			for entry_mode in ('full','short'):
+				for a,word in enumerate(m.wl):
+					b = m.idx(word,entry_mode)
+					assert a == b, '{} != {} ({!r} - entry mode: {!r})'.format(a,b,word,entry_mode)
+				a = None
+				for word in junk.split():
+					b = m.idx(word,entry_mode)
+					assert a == b, '{} != {} ({!r} - entry mode: {!r})'.format(a,b,word,entry_mode)
+			if 'idx_minimal' in self.vectors[wl_id]:
+				for vec in self.vectors[wl_id]['idx_minimal']:
+					chk = vec[1]
+					b = m.idx(vec[0],'minimal')
+					if chk is False:
+						assert b is None, (b,None)
+					elif chk is None:
+						assert type(b) == tuple, (type(b),tuple)
+					elif type(chk) is int:
+						assert b == chk, (b,chk)
+		msg('OK')
+
+		return True