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
This commit is contained in:
The MMGen Project 2020-03-12 17:12:43 +00:00
commit 04add0dfa5
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
13 changed files with 631 additions and 62 deletions

View file

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

View file

@ -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 ##

View file

@ -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
mmgen/mn_entry.py Executable file
View file

@ -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 youve 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 its 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 thats 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]))

View file

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

View file

@ -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']()

View file

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

View file

@ -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'

View file

@ -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
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,(12,8)[fmt=='bip39'])
mn = pad_mnemonic(mn,mne.em.ss_len)
mn[10] = '@#$%*##' + mn[10]
wnum,em,rm = 1,'Enter word #{}: ','Repeat word #{}: '
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)):

View file

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

View file

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

View file

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