From 04add0dfa54239b7d4895290d94d0468efc07061 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Thu, 12 Mar 2020 17:12:43 +0000 Subject: [PATCH] new mnemonic entry modes, new 'mn2hex_interactive' tool command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 + data_files/mmgen.cfg | 4 + mmgen/globalvars.py | 4 +- mmgen/mn_entry.py | 419 +++++++++++++++++++++++++++++++ mmgen/seed.py | 51 +--- mmgen/tool.py | 8 + setup.py | 1 + test/common.py | 14 ++ test/ref/mmgen.cfg | 2 +- test/test_py_d/common.py | 32 ++- test/test_py_d/ts_autosign.py | 6 +- test/test_py_d/ts_input.py | 66 ++++- test/unit_tests_d/ut_mn_entry.py | 82 ++++++ 13 files changed, 630 insertions(+), 61 deletions(-) create mode 100755 mmgen/mn_entry.py create mode 100755 test/unit_tests_d/ut_mn_entry.py 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