#!/usr/bin/env python3 # # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution # Copyright (C)2013-2024 The MMGen Project # # 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')