diff --git a/README.md b/README.md index 782a81d9..e7f0553a 100644 --- a/README.md +++ b/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 diff --git a/data_files/mmgen.cfg b/data_files/mmgen.cfg index 9136aa35..3dd5a9f4 100644 --- a/data_files/mmgen.cfg +++ b/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 ## diff --git a/mmgen/globalvars.py b/mmgen/globalvars.py index d7766534..65e0b46f 100755 --- a/mmgen/globalvars.py +++ b/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' diff --git a/mmgen/mn_entry.py b/mmgen/mn_entry.py new file mode 100755 index 00000000..2b6c35f7 --- /dev/null +++ b/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 +# +# 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 . + +""" +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])) diff --git a/mmgen/seed.py b/mmgen/seed.py index b5f2d383..a1055d28 100755 --- a/mmgen/seed.py +++ b/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 diff --git a/mmgen/tool.py b/mmgen/tool.py index 1445751a..7e1e87eb 100755 --- a/mmgen/tool.py +++ b/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']() diff --git a/setup.py b/setup.py index 170d033a..f69506c1 100755 --- a/setup.py +++ b/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', diff --git a/test/common.py b/test/common.py index b2a2d7e4..7491ce97 100755 --- a/test/common.py +++ b/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' diff --git a/test/ref/mmgen.cfg b/test/ref/mmgen.cfg index 3dd5a9f4..76bc578c 100644 --- a/test/ref/mmgen.cfg +++ b/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 ##################### diff --git a/test/test_py_d/common.py b/test/test_py_d/common.py index f5eb31b4..f97dab04 100755 --- a/test/test_py_d/common.py +++ b/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']) + 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,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)): diff --git a/test/test_py_d/ts_autosign.py b/test/test_py_d/ts_autosign.py index 9d284b54..4203a3b9 100755 --- a/test/test_py_d/ts_autosign.py +++ b/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() diff --git a/test/test_py_d/ts_input.py b/test/test_py_d/ts_input.py index 1e9f7120..d796004b 100755 --- a/test/test_py_d/ts_input.py +++ b/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') diff --git a/test/unit_tests_d/ut_mn_entry.py b/test/unit_tests_d/ut_mn_entry.py new file mode 100755 index 00000000..0fc762a5 --- /dev/null +++ b/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