mmgen-wallet/test/cmdtest_py_d/ct_input.py

485 lines
16 KiB
Python
Executable file

#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
#
# Project source code repository: https://github.com/mmgen/mmgen-wallet
# Licensed according to the terms of GPL Version 3. See LICENSE for details.
"""
test.cmdtest_py_d.ct_input: user input tests for the MMGen cmdtest.py test suite
"""
import sys,os
from mmgen.cfg import gc
from mmgen.util import fmt,capfirst,remove_whitespace
from mmgen.wallet import get_wallet_cls
from ..include.common import (
cfg,
imsg,
imsg_r,
sample_mn,
get_data_from_file,
read_from_file,
strip_ansi_escapes
)
from .common import Ctrl_U,ref_dir
from .ct_base import CmdTestBase
from .input import stealth_mnemonic_entry,user_dieroll_entry
class CmdTestInput(CmdTestBase):
'user input'
networks = ('btc',)
tmpdir_nums = [1]
color = True
cmd_group_in = (
('subgroup.char', []),
('subgroup.line', []),
('subgroup.password', []),
('subgroup.misc', []),
('subgroup.wallet', []),
('subgroup.mnemonic', []),
('subgroup.dieroll', []),
)
cmd_subgroups = {
'char': (
'get_char() function',
('get_char1', 'get_char()'),
('get_char2', 'get_char() [multiple characters]'),
('get_char3', 'get_char() [no prompt]'),
('get_char4', 'get_char() [utf8]'),
('get_char_term1', 'get_char() [term, utf8]'),
('get_char_term2', 'get_char() [term, multiple characters]'),
('get_char_term3', 'get_char() [term, prehold_protect=False]'),
('get_char_term4', 'get_char() [term, immed_chars="xyz"]'),
),
'line': (
'line_input() function',
('line_input', 'line_input()'),
('line_input_term1', 'line_input() [term]'),
('line_input_term2', 'line_input() [term, no hold protect]'),
('line_input_insert', 'line_input() [inserted text]'),
('line_input_insert_term1', 'line_input() [inserted text, term]'),
('line_input_insert_term2', 'line_input() [inserted text, term, no hold protect]'),
('line_input_edit_term', 'line_input() [inserted + edited text, term, utf8]'),
('line_input_erase_term', 'line_input() [inserted + erased text, term]'),
),
'password': (
'password entry via line_input()',
('password_entry_noecho', 'utf8 password entry'),
('password_entry_noecho_term', 'utf8 password entry [term]'),
('password_entry_echo', 'utf8 password entry (echoed)'),
('password_entry_echo_term', 'utf8 password entry (echoed) [term]'),
),
'misc': (
'miscellaneous user-level UI functions',
('get_seed_from_stdin', 'reading seed phrase from STDIN'),
),
'wallet': (
'hash preset, password and label entry',
('get_passphrase_ui', 'hash preset, password and label (wallet.py)'),
('get_passphrase_cmdline', 'hash preset, password and label (wallet.py - from cmdline)'),
('get_passphrase_crypto', 'hash preset, password and label (crypto.py)'),
),
'mnemonic': (
'mnemonic entry',
('mnemonic_entry_mmgen', 'stealth mnemonic entry (mmgen)'),
('mnemonic_entry_mmgen_minimal', 'stealth mnemonic entry (mmgen - minimal entry mode)'),
('mnemonic_entry_bip39', 'stealth mnemonic entry (bip39)'),
('mnemonic_entry_bip39_short', 'stealth mnemonic entry (bip39 - short entry mode)'),
('mn2hex_interactive_mmgen', 'mn2hex_interactive (mmgen)'),
('mn2hex_interactive_mmgen_fixed','mn2hex_interactive (mmgen - fixed (10-letter) entry mode)'),
('mn2hex_interactive_bip39', 'mn2hex_interactive (bip39)'),
('mn2hex_interactive_bip39_short','mn2hex_interactive (bip39 - short entry mode (+pad entry))'),
('mn2hex_interactive_bip39_fixed','mn2hex_interactive (bip39 - fixed (4-letter) entry mode)'),
('mn2hex_interactive_xmr', 'mn2hex_interactive (xmrseed)'),
('mn2hex_interactive_xmr_short', 'mn2hex_interactive (xmrseed - short entry mode)'),
),
'dieroll': (
'dieroll entry',
('dieroll_entry', 'dieroll entry (base6d)'),
('dieroll_entry_usrrand', 'dieroll entry (base6d) with added user entropy'),
)
}
def get_seed_from_stdin(self):
self.spawn('',msg_only=True)
from subprocess import run,PIPE
cmd = ['python3','cmds/mmgen-walletconv','--in-fmt=words','--out-fmt=words','--outdir=test/trash']
mn = sample_mn['mmgen']['mn']
run_env = dict(os.environ)
run_env['MMGEN_TEST_SUITE'] = ''
# the test can fail the first time if cfg file has changed, so run it twice if necessary:
for _ in range(2):
cp = run( cmd, input=mn.encode(), stdout=PIPE, stderr=PIPE, env=run_env )
if b'written to file' in cp.stderr:
break
from mmgen.color import set_vt100
set_vt100()
imsg(cp.stderr.decode().strip())
res = get_data_from_file(cfg,'test/trash/A773B05C[128].mmwords',silent=True).strip()
assert res == mn, f'{res} != {mn}'
return 'ok' if b'written to file' in cp.stderr else 'error'
def get_passphrase_ui(self):
t = self.spawn('test/misc/get_passphrase.py',['--usr-randchars=0','seed'],cmd_dir='.')
# 1 - new wallet, default hp,label;empty pw
t.expect('accept the default.*: ','\n',regex=True)
# bad repeat
t.expect('new MMGen wallet: ','pass1\n')
t.expect('peat passphrase: ','pass2\n')
# good repeat
t.expect('new MMGen wallet: ','\n')
t.expect('peat passphrase: ','\n')
t.expect('mpty pass')
t.expect('no label: ','\n')
t.expect('[][3][No Label]')
# 2 - new wallet, user-selected hp,pw,label
t.expect('accept the default.*: ', '1\n', regex=True)
t.expect('new MMGen wallet: ','pass1\n')
t.expect('peat passphrase: ','pass1\n')
t.expect('no label: ','lbl1\n')
t.expect('[pass1][1][lbl1]')
# 3 - passchg, nothing changes
t.expect('new hash preset')
t.expect('reuse the old value.*: ','\n',regex=True)
t.expect('unchanged')
t.expect('new passphrase.*: ','pass1\n',regex=True)
t.expect('peat new passphrase: ','pass1\n')
t.expect('unchanged')
t.expect('reuse the label .*: ','\n',regex=True)
t.expect('unchanged')
t.expect('[pass1][1][lbl1]')
# 4 - passchg, everything changes
t.expect('new hash preset')
t.expect('reuse the old value.*: ','2\n',regex=True)
t.expect(' changed to')
t.expect('new passphrase.*: ','pass2\n',regex=True)
t.expect('peat new passphrase: ','pass2\n')
t.expect(' changed')
t.expect('reuse the label .*: ','lbl2\n',regex=True)
t.expect(' changed to')
t.expect('[pass2][2][lbl2]')
# 5 - wallet from file
t.expect('from file')
# bad passphrase
t.expect('passphrase for MMGen wallet: ','bad\n')
t.expect('Trying again')
# good passphrase
t.expect('passphrase for MMGen wallet: ','reference password\n')
t.expect('[reference password][1][No Label]')
return t
def get_passphrase_cmdline(self):
with open('test/trash/pwfile','w') as fp:
fp.write('reference password\n')
t = self.spawn('test/misc/get_passphrase.py', [
'--usr-randchars=0',
'--label=MyLabel',
'--passwd-file=test/trash/pwfile',
'--hash-preset=1',
'seed' ],
cmd_dir = '.' )
for _ in range(4):
t.expect('[reference password][1][MyLabel]')
return t
def get_passphrase_crypto(self):
t = self.spawn('test/misc/get_passphrase.py',['--usr-randchars=0','crypto'],cmd_dir='.')
# new passwd
t.expect('passphrase for .*: ', 'x\n', regex=True)
t.expect('peat passphrase: ', '\n')
t.expect('passphrase for .*: ', 'pass1\n', regex=True)
t.expect('peat passphrase: ', 'pass1\n')
t.expect('[pass1]')
# existing passwd
t.expect('passphrase for .*: ', 'pass2\n', regex=True)
t.expect('[pass2]')
# hash preset
t.expect('accept the default .*: ', '0\n', regex=True)
t.expect('nvalid')
t.expect('accept the default .*: ', '8\n', regex=True)
t.expect('nvalid')
t.expect('accept the default .*: ', '7\n', regex=True)
t.expect('[7]')
# hash preset (default)
t.expect('accept the default .*: ', '\n', regex=True)
t.expect(f'[{gc.dfl_hash_preset}]')
return t
def _input_func(self,func_name,arg_dfls,func_args,text,expect,term):
if term and sys.platform == 'win32':
return ('skip_warn','pexpect_spawn not supported on Windows platform')
func_args = dict(zip(arg_dfls.keys(),func_args))
t = self.spawn(
'test/misc/input_func.py',
[func_name,repr(func_args)],
cmd_dir='.',
pexpect_spawn=term )
imsg('Parameters:')
imsg(f' pexpect_spawn: {term}')
imsg(f' sending: {text!r}')
imsg(f' expecting: {expect!r}')
imsg('\nFunction args:')
for k,v in func_args.items():
imsg(' {:14} {!r}'.format(k+':',v))
imsg_r('\nScript output: ')
prompt_add = (func_args['insert_txt'] if term else '') if func_name == 'line_input' else ''
prompt = func_args['prompt'] + prompt_add
t.expect('Calling ')
if prompt:
t.expect(prompt,text)
else:
t.send(text)
ret = t.expect_getend(' ==> ')
assert ret == repr(expect), f'Text mismatch! {ret} != {repr(expect)}'
return t
def _get_char(self,func_args,text,expect,term):
arg_dfls = {
'prompt': '',
'immed_chars': '',
'prehold_protect': True,
'num_bytes': 5,
}
return self._input_func('get_char',arg_dfls,func_args,text,expect,term)
def _line_input(self,func_args,text,expect,term):
arg_dfls = {
'prompt': '', # positional
'echo': True,
'insert_txt': '',
'hold_protect': True,
}
return self._input_func('line_input',arg_dfls,func_args,text+'\n',expect,term)
def get_char1(self):
return self._get_char(['prompt> ','',True,5],'x','x',False)
def get_char2(self):
expect = 'x' if sys.platform == 'win32' else 'xxxxx'
return self._get_char(['prompt> ','',True,5],'xxxxx',expect,False)
def get_char3(self):
return self._get_char(['','',True,5],'x','x',False)
def get_char4(self):
return self._get_char(['prompt> ','',True,2],'α','α',False) # UTF-8, must get 2 bytes
def get_char_term1(self):
return self._get_char(['prompt> ','',True,2],'β','β',True) # UTF-8, must get 2 bytes
def get_char_term2(self):
return self._get_char(['prompt> ','',True,5],'xxxxx','xxxxx',True)
def get_char_term3(self):
return self._get_char(['','',False,5],'x','x',True)
def get_char_term4(self):
return self._get_char(['prompt> ','xyz',False,5],'x','x',True)
def line_input(self):
return self._line_input(['prompt> ',True,'',True],'foo','foo',False)
def line_input_term1(self):
return self._line_input(['prompt> ',True,'',True],'foo','foo',True)
def line_input_term2(self):
return self._line_input(['prompt> ',True,'',False],'foo','foo',True)
def line_input_insert(self):
return self._line_input(['prompt> ',True,'inserted text',True],'foo','foo',False)
def line_input_insert_term1(self):
return self._line_input(['prompt> ',True,'foo',True],'bar','foobar',True)
def line_input_insert_term2(self):
return self._line_input(['prompt> ',True,'foo',False],'bar','foobar',True)
def line_input_edit_term(self):
return self._line_input(['prompt> ',True,'φυφυ',True],'\b\bβαρ','φυβαρ',True)
def line_input_erase_term(self):
return self._line_input(['prompt> ',True,'foobarbaz',True],Ctrl_U+'foobar','foobar',True)
def _password_entry(self,prompt,opts=[],term=False):
if term and sys.platform == 'win32':
return ('skip_warn','pexpect_spawn not supported on Windows platform')
t = self.spawn( 'test/misc/input_func.py', opts + ['passphrase'], cmd_dir='.', pexpect_spawn=term )
imsg(f'Terminal: {term}')
pw = 'abc-α'
t.expect(prompt,pw+'\n')
ret = t.expect_getend('Entered: ')
assert ret == pw, f'Password mismatch! {ret} != {pw}'
return t
winskip_msg = """
pexpect_spawn not supported on Windows platform
Perform the following test by hand with non-ASCII password abc-α
or another password in your native alphabet:
test/misc/input_func.py{} passphrase
"""
def password_entry_noecho(self,term=False):
return self._password_entry('Enter passphrase: ',term=term)
def password_entry_noecho_term(self):
if self.skip_for_win():
return ('skip_warn','\n' + fmt(self.winskip_msg.format(''),strip_char='\t'))
return self.password_entry_noecho(term=True)
def password_entry_echo(self,term=False):
return self._password_entry('Enter passphrase (echoed): ',['--echo-passphrase'],term=term)
def password_entry_echo_term(self):
if self.skip_for_win():
return ('skip_warn','\n' + fmt(self.winskip_msg.format(' --echo-passphrase'),strip_char='\t'))
return self.password_entry_echo(term=True)
def _mn2hex(self,fmt,entry_mode='full',mn=None,pad_entry=False,enter_for_dfl=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( cfg, fmt, entry_mode )
t.expect(
'Type a number.*: ',
('\n' if enter_for_dfl else str(mne.entry_modes.index(entry_mode)+1)),
regex = True )
t.expect('Using entry mode (\S+)',regex=True)
mode = strip_ansi_escapes(t.p.match.group(1)).lower()
assert mode == mne.em.name.lower(), f'{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'])
return t
def _user_seed_entry(
self,
fmt,
usr_rand = False,
out_fmt = None,
entry_mode = 'full',
mn = None,
seedlen_opt = False):
wcls = get_wallet_cls(fmt_code=fmt)
wf = os.path.join(ref_dir,f'FE3C6545.{wcls.ext}')
if wcls.base_type == 'mnemonic':
mn = mn or read_from_file(wf).strip().split()
elif wcls.type == 'dieroll':
mn = mn or list(remove_whitespace(read_from_file(wf)))
for idx,val in ((5,'x'),(18,'0'),(30,'7'),(44,'9')):
mn.insert(idx,val)
t = self.spawn(
'mmgen-walletconv',
['--usr-randchars=10', '--stdout']
+ (['--seed-len=128'] if seedlen_opt else [])
+ [f'--in-fmt={fmt}', f'--out-fmt={out_fmt or fmt}']
)
t.expect(f'{capfirst(wcls.base_type or wcls.type)} type:.*{wcls.mn_type}',regex=True)
if not seedlen_opt:
t.expect(wcls.choose_seedlen_prompt,'1')
t.expect('(Y/n): ','y')
if wcls.base_type == 'mnemonic':
t.expect('Type a number.*: ','6',regex=True)
t.expect('invalid')
from mmgen.mn_entry import mn_entry
mne = mn_entry( cfg, fmt, entry_mode )
t.expect('Type a number.*: ',str(mne.entry_modes.index(entry_mode)+1),regex=True)
t.expect('Using entry mode (\S+)',regex=True)
mode = strip_ansi_escapes(t.p.match.group(1)).lower()
assert mode == mne.em.name.lower(), f'{mode} != {mne.em.name.lower()}'
stealth_mnemonic_entry(t,mne,mn,entry_mode=entry_mode)
elif wcls.type == '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(f'Valid {wcls.desc} for Seed ID')
sid = strip_ansi_escapes(sid.split(',')[0])
assert sid_chk in sid, f'Seed ID mismatch! {sid_chk} not found in {sid}'
t.expect('to confirm: ','YES\n')
return t
def mnemonic_entry_mmgen_minimal(self):
from mmgen.mn_entry import mn_entry
# erase_chars: '\b\x7f'
m = mn_entry( cfg, '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',enter_for_dfl=True)
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', seedlen_opt=True)
def dieroll_entry_usrrand(self):
return self._user_seed_entry('dieroll',usr_rand=True,out_fmt='bip39')