mmgen-wallet/test/test_py_d/ts_input.py
The MMGen Project 04add0dfa5
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
2020-03-12 17:12:43 +00:00

150 lines
6.6 KiB
Python
Executable file

#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
# Copyright (C)2013-2020 The MMGen Project <mmgen@tuta.io>
#
# Project source code repository: https://github.com/mmgen/mmgen
# Licensed according to the terms of GPL Version 3. See LICENSE for details.
"""
ts_input.py: user input tests for the MMGen test.py test suite
"""
from test.common import *
from test.test_py_d.ts_base import *
from mmgen.seed import SeedSource
class TestSuiteInput(TestSuiteBase):
'user input'
networks = ('btc',)
tmpdir_nums = []
cmd_group = (
('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", [])),
)
def password_entry(self,prompt,cmd_args):
t = self.spawn('test/misc/password_entry.py',cmd_args,cmd_dir='.')
pw = 'abc-α'
t.expect(prompt,pw)
ret = t.expect_getend('Entered: ')
assert ret == pw,'Password mismatch! {} != {}'.format(ret,pw)
return t
def password_entry_noecho(self):
if self.skip_for_win():
m = "getpass() doesn't work with pexpect.popen_spawn!\n"
m += 'Perform the following test by hand with non-ASCII password abc-α:\n'
m += ' test/misc/password_entry.py'
return ('skip_warn',m)
return self.password_entry('Enter passphrase: ',[])
def password_entry_echo(self):
if self.skip_for_win():
m = "getpass() doesn't work with pexpect.popen_spawn!\n"
m += 'Perform the following test by hand with non-ASCII password abc-α:\n'
m += ' test/misc/password_entry.py --echo-passphrase'
return ('skip_warn',m)
return self.password_entry('Enter passphrase (echoed): ',['--echo-passphrase'])
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':
mn = mn or read_from_file(wf).strip().split()
elif wcls.wclass == 'dieroll':
mn = mn or list(read_from_file(wf).strip().translate(dict((ord(ws),None) for ws in '\t\n ')))
for idx,val in ((5,'x'),(18,'0'),(30,'7'),(44,'9')):
mn.insert(idx,val)
t = self.spawn('mmgen-walletconv',['-r10','-S','-i',fmt,'-o',out_fmt or fmt])
t.expect('{} type: {}'.format(capfirst(wcls.wclass),wcls.mn_type))
t.expect(wcls.choose_seedlen_prompt,'1')
t.expect('(Y/n): ','y')
if wcls.wclass == 'mnemonic':
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:
t.expect(wcls.user_entropy_prompt,'y')
t.usr_rand(10)
else:
t.expect(wcls.user_entropy_prompt,'n')
if not usr_rand:
sid_chk = 'FE3C6545'
sid = t.expect_getend('Valid {} for Seed ID '.format(wcls.desc))[:8]
assert sid == sid_chk,'Seed ID mismatch! {} != {}'.format(sid,sid_chk)
t.expect('to confirm: ','YES\n')
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')