ts_input.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2023 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # Project source code repository: https://github.com/mmgen/mmgen
  7. # Licensed according to the terms of GPL Version 3. See LICENSE for details.
  8. """
  9. test.test_py_d.ts_input: user input tests for the MMGen test.py test suite
  10. """
  11. import os,time
  12. from ..include.common import (
  13. cfg,
  14. imsg,
  15. imsg_r,
  16. sample_mn,
  17. get_data_from_file,
  18. read_from_file,
  19. strip_ansi_escapes
  20. )
  21. from .common import Ctrl_U,ref_dir
  22. from .ts_base import TestSuiteBase
  23. from .input import stealth_mnemonic_entry,user_dieroll_entry
  24. from mmgen.cfg import gc
  25. from mmgen.color import set_vt100
  26. from mmgen.util import fmt,capfirst,remove_whitespace
  27. from mmgen.wallet import get_wallet_cls
  28. class TestSuiteInput(TestSuiteBase):
  29. 'user input'
  30. networks = ('btc',)
  31. tmpdir_nums = [1]
  32. color = True
  33. cmd_group_in = (
  34. ('subgroup.char', []),
  35. ('subgroup.line', []),
  36. ('subgroup.password', []),
  37. ('subgroup.misc', []),
  38. ('subgroup.wallet', []),
  39. ('subgroup.mnemonic', []),
  40. ('subgroup.dieroll', []),
  41. )
  42. cmd_subgroups = {
  43. 'char': (
  44. 'get_char() function',
  45. ('get_char1', 'get_char()'),
  46. ('get_char2', 'get_char() [multiple characters]'),
  47. ('get_char3', 'get_char() [no prompt]'),
  48. ('get_char4', 'get_char() [utf8]'),
  49. ('get_char_term1', 'get_char() [term, utf8]'),
  50. ('get_char_term2', 'get_char() [term, multiple characters]'),
  51. ('get_char_term3', 'get_char() [term, prehold_protect=False]'),
  52. ('get_char_term4', 'get_char() [term, immed_chars="xyz"]'),
  53. ),
  54. 'line': (
  55. 'line_input() function',
  56. ('line_input', 'line_input()'),
  57. ('line_input_term1', 'line_input() [term]'),
  58. ('line_input_term2', 'line_input() [term, no hold protect]'),
  59. ('line_input_insert', 'line_input() [inserted text]'),
  60. ('line_input_insert_term1', 'line_input() [inserted text, term]'),
  61. ('line_input_insert_term2', 'line_input() [inserted text, term, no hold protect]'),
  62. ('line_input_edit_term', 'line_input() [inserted + edited text, term, utf8]'),
  63. ('line_input_erase_term', 'line_input() [inserted + erased text, term]'),
  64. ),
  65. 'password': (
  66. 'password entry via line_input()',
  67. ('password_entry_noecho', 'utf8 password entry'),
  68. ('password_entry_noecho_term', 'utf8 password entry [term]'),
  69. ('password_entry_echo', 'utf8 password entry (echoed)'),
  70. ('password_entry_echo_term', 'utf8 password entry (echoed) [term]'),
  71. ),
  72. 'misc': (
  73. 'miscellaneous user-level UI functions',
  74. ('get_seed_from_stdin', 'reading seed phrase from STDIN'),
  75. ),
  76. 'wallet': (
  77. 'hash preset, password and label entry',
  78. ('get_passphrase_ui', 'hash preset, password and label (wallet.py)'),
  79. ('get_passphrase_cmdline', 'hash preset, password and label (wallet.py - from cmdline)'),
  80. ('get_passphrase_crypto', 'hash preset, password and label (crypto.py)'),
  81. ),
  82. 'mnemonic': (
  83. 'mnemonic entry',
  84. ('mnemonic_entry_mmgen', 'stealth mnemonic entry (mmgen)'),
  85. ('mnemonic_entry_mmgen_minimal', 'stealth mnemonic entry (mmgen - minimal entry mode)'),
  86. ('mnemonic_entry_bip39', 'stealth mnemonic entry (bip39)'),
  87. ('mnemonic_entry_bip39_short', 'stealth mnemonic entry (bip39 - short entry mode)'),
  88. ('mn2hex_interactive_mmgen', 'mn2hex_interactive (mmgen)'),
  89. ('mn2hex_interactive_mmgen_fixed','mn2hex_interactive (mmgen - fixed (10-letter) entry mode)'),
  90. ('mn2hex_interactive_bip39', 'mn2hex_interactive (bip39)'),
  91. ('mn2hex_interactive_bip39_short','mn2hex_interactive (bip39 - short entry mode (+pad entry))'),
  92. ('mn2hex_interactive_bip39_fixed','mn2hex_interactive (bip39 - fixed (4-letter) entry mode)'),
  93. ('mn2hex_interactive_xmr', 'mn2hex_interactive (xmrseed)'),
  94. ('mn2hex_interactive_xmr_short', 'mn2hex_interactive (xmrseed - short entry mode)'),
  95. ),
  96. 'dieroll': (
  97. 'dieroll entry',
  98. ('dieroll_entry', 'dieroll entry (base6d)'),
  99. ('dieroll_entry_usrrand', 'dieroll entry (base6d) with added user entropy'),
  100. )
  101. }
  102. def get_seed_from_stdin(self):
  103. self.spawn('',msg_only=True)
  104. from subprocess import run,PIPE
  105. cmd = ['python3','cmds/mmgen-walletconv','--in-fmt=words','--out-fmt=words','--outdir=test/trash']
  106. mn = sample_mn['mmgen']['mn']
  107. run_env = dict(os.environ)
  108. run_env['MMGEN_TEST_SUITE'] = ''
  109. # the test can fail the first time if cfg file has changed, so run it twice if necessary:
  110. for i in range(2):
  111. cp = run( cmd, input=mn.encode(), stdout=PIPE, stderr=PIPE, env=run_env )
  112. if b'written to file' in cp.stderr:
  113. break
  114. from mmgen.color import set_vt100
  115. set_vt100()
  116. imsg(cp.stderr.decode().strip())
  117. res = get_data_from_file(cfg,'test/trash/A773B05C[128].mmwords',silent=True).strip()
  118. assert res == mn, f'{res} != {mn}'
  119. return 'ok' if b'written to file' in cp.stderr else 'error'
  120. def get_passphrase_ui(self):
  121. t = self.spawn('test/misc/get_passphrase.py',['--usr-randchars=0','seed'],cmd_dir='.')
  122. # 1 - new wallet, default hp,label;empty pw
  123. t.expect('accept the default.*: ','\n',regex=True)
  124. # bad repeat
  125. t.expect('new MMGen wallet: ','pass1\n')
  126. t.expect('peat passphrase: ','pass2\n')
  127. # good repeat
  128. t.expect('new MMGen wallet: ','\n')
  129. t.expect('peat passphrase: ','\n')
  130. t.expect('mpty pass')
  131. t.expect('no label: ','\n')
  132. t.expect('[][3][No Label]')
  133. # 2 - new wallet, user-selected hp,pw,label
  134. t.expect('accept the default.*: ', '1\n', regex=True)
  135. t.expect('new MMGen wallet: ','pass1\n')
  136. t.expect('peat passphrase: ','pass1\n')
  137. t.expect('no label: ','lbl1\n')
  138. t.expect('[pass1][1][lbl1]')
  139. # 3 - passchg, nothing changes
  140. t.expect('new hash preset')
  141. t.expect('reuse the old value.*: ','\n',regex=True)
  142. t.expect('unchanged')
  143. t.expect('new passphrase.*: ','pass1\n',regex=True)
  144. t.expect('peat new passphrase: ','pass1\n')
  145. t.expect('unchanged')
  146. t.expect('reuse the label .*: ','\n',regex=True)
  147. t.expect('unchanged')
  148. t.expect('[pass1][1][lbl1]')
  149. # 4 - passchg, everything changes
  150. t.expect('new hash preset')
  151. t.expect('reuse the old value.*: ','2\n',regex=True)
  152. t.expect(' changed to')
  153. t.expect('new passphrase.*: ','pass2\n',regex=True)
  154. t.expect('peat new passphrase: ','pass2\n')
  155. t.expect(' changed')
  156. t.expect('reuse the label .*: ','lbl2\n',regex=True)
  157. t.expect(' changed to')
  158. t.expect('[pass2][2][lbl2]')
  159. # 5 - wallet from file
  160. t.expect('from file')
  161. # bad passphrase
  162. t.expect('passphrase for MMGen wallet: ','bad\n')
  163. t.expect('Trying again')
  164. # good passphrase
  165. t.expect('passphrase for MMGen wallet: ','reference password\n')
  166. t.expect('[reference password][1][No Label]')
  167. return t
  168. def get_passphrase_cmdline(self):
  169. with open('test/trash/pwfile','w') as fp:
  170. fp.write('reference password\n')
  171. t = self.spawn('test/misc/get_passphrase.py', [
  172. '--usr-randchars=0',
  173. '--label=MyLabel',
  174. '--passwd-file=test/trash/pwfile',
  175. '--hash-preset=1',
  176. 'seed' ],
  177. cmd_dir = '.' )
  178. for foo in range(4):
  179. t.expect('[reference password][1][MyLabel]')
  180. return t
  181. def get_passphrase_crypto(self):
  182. t = self.spawn('test/misc/get_passphrase.py',['--usr-randchars=0','crypto'],cmd_dir='.')
  183. # new passwd
  184. t.expect('passphrase for .*: ', 'x\n', regex=True)
  185. t.expect('peat passphrase: ', '\n')
  186. t.expect('passphrase for .*: ', 'pass1\n', regex=True)
  187. t.expect('peat passphrase: ', 'pass1\n')
  188. t.expect('[pass1]')
  189. # existing passwd
  190. t.expect('passphrase for .*: ', 'pass2\n', regex=True)
  191. t.expect('[pass2]')
  192. # hash preset
  193. t.expect('accept the default .*: ', '0\n', regex=True)
  194. t.expect('nvalid')
  195. t.expect('accept the default .*: ', '8\n', regex=True)
  196. t.expect('nvalid')
  197. t.expect('accept the default .*: ', '7\n', regex=True)
  198. t.expect('[7]')
  199. # hash preset (default)
  200. t.expect('accept the default .*: ', '\n', regex=True)
  201. t.expect(f'[{gc.dfl_hash_preset}]')
  202. return t
  203. def _input_func(self,func_name,arg_dfls,func_args,text,expect,term):
  204. if term and gc.platform == 'win':
  205. return ('skip_warn','pexpect_spawn not supported on Windows platform')
  206. func_args = {k:v for k,v in zip(arg_dfls.keys(),func_args)}
  207. t = self.spawn(
  208. 'test/misc/input_func.py',
  209. [func_name,repr(func_args)],
  210. cmd_dir='.',
  211. pexpect_spawn=term )
  212. imsg('Parameters:')
  213. imsg(' pexpect_spawn: {}'.format(term))
  214. imsg(' sending: {!r}'.format(text))
  215. imsg(' expecting: {!r}'.format(expect))
  216. imsg('\nFunction args:')
  217. for k,v in func_args.items():
  218. imsg(' {:14} {!r}'.format(k+':',v))
  219. imsg_r('\nScript output: ')
  220. prompt_add = (func_args['insert_txt'] if term else '') if func_name == 'line_input' else ''
  221. prompt = func_args['prompt'] + prompt_add
  222. t.expect('Calling ')
  223. if prompt:
  224. t.expect(prompt,text)
  225. else:
  226. t.send(text)
  227. ret = t.expect_getend(' ==> ')
  228. assert ret == repr(expect), f'Text mismatch! {ret} != {repr(expect)}'
  229. return t
  230. def _get_char(self,func_args,text,expect,term):
  231. arg_dfls = {
  232. 'prompt': '',
  233. 'immed_chars': '',
  234. 'prehold_protect': True,
  235. 'num_bytes': 5,
  236. }
  237. return self._input_func('get_char',arg_dfls,func_args,text,expect,term)
  238. def _line_input(self,func_args,text,expect,term):
  239. arg_dfls = {
  240. 'prompt': '', # positional
  241. 'echo': True,
  242. 'insert_txt': '',
  243. 'hold_protect': True,
  244. }
  245. return self._input_func('line_input',arg_dfls,func_args,text+'\n',expect,term)
  246. def get_char1(self):
  247. return self._get_char(['prompt> ','',True,5],'x','x',False)
  248. def get_char2(self):
  249. expect = 'x' if gc.platform == 'win' else 'xxxxx'
  250. return self._get_char(['prompt> ','',True,5],'xxxxx',expect,False)
  251. def get_char3(self):
  252. return self._get_char(['','',True,5],'x','x',False)
  253. def get_char4(self):
  254. return self._get_char(['prompt> ','',True,2],'α','α',False) # UTF-8, must get 2 bytes
  255. def get_char_term1(self):
  256. return self._get_char(['prompt> ','',True,2],'β','β',True) # UTF-8, must get 2 bytes
  257. def get_char_term2(self):
  258. return self._get_char(['prompt> ','',True,5],'xxxxx','xxxxx',True)
  259. def get_char_term3(self):
  260. return self._get_char(['','',False,5],'x','x',True)
  261. def get_char_term4(self):
  262. return self._get_char(['prompt> ','xyz',False,5],'x','x',True)
  263. def line_input(self):
  264. return self._line_input(['prompt> ',True,'',True],'foo','foo',False)
  265. def line_input_term1(self):
  266. return self._line_input(['prompt> ',True,'',True],'foo','foo',True)
  267. def line_input_term2(self):
  268. return self._line_input(['prompt> ',True,'',False],'foo','foo',True)
  269. def line_input_insert(self):
  270. return self._line_input(['prompt> ',True,'inserted text',True],'foo','foo',False)
  271. def line_input_insert_term1(self):
  272. return self._line_input(['prompt> ',True,'foo',True],'bar','foobar',True)
  273. def line_input_insert_term2(self):
  274. return self._line_input(['prompt> ',True,'foo',False],'bar','foobar',True)
  275. def line_input_edit_term(self):
  276. return self._line_input(['prompt> ',True,'φυφυ',True],'\b\bβαρ','φυβαρ',True)
  277. def line_input_erase_term(self):
  278. return self._line_input(['prompt> ',True,'foobarbaz',True],Ctrl_U+'foobar','foobar',True)
  279. def _password_entry(self,prompt,opts=[],term=False):
  280. if term and gc.platform == 'win':
  281. return ('skip_warn','pexpect_spawn not supported on Windows platform')
  282. t = self.spawn( 'test/misc/input_func.py', opts + ['passphrase'], cmd_dir='.', pexpect_spawn=term )
  283. imsg('Terminal: {}'.format(term))
  284. pw = 'abc-α'
  285. t.expect(prompt,pw+'\n')
  286. ret = t.expect_getend('Entered: ')
  287. assert ret == pw, f'Password mismatch! {ret} != {pw}'
  288. return t
  289. winskip_msg = """
  290. pexpect_spawn not supported on Windows platform
  291. Perform the following test by hand with non-ASCII password abc-α
  292. or another password in your native alphabet:
  293. test/misc/input_func.py{} passphrase
  294. """
  295. def password_entry_noecho(self,term=False):
  296. return self._password_entry('Enter passphrase: ',term=term)
  297. def password_entry_noecho_term(self):
  298. if self.skip_for_win():
  299. return ('skip_warn','\n' + fmt(self.winskip_msg.format(''),strip_char='\t'))
  300. return self.password_entry_noecho(term=True)
  301. def password_entry_echo(self,term=False):
  302. return self._password_entry('Enter passphrase (echoed): ',['--echo-passphrase'],term=term)
  303. def password_entry_echo_term(self):
  304. if self.skip_for_win():
  305. return ('skip_warn','\n' + fmt(self.winskip_msg.format(' --echo-passphrase'),strip_char='\t'))
  306. return self.password_entry_echo(term=True)
  307. def _mn2hex(self,fmt,entry_mode='full',mn=None,pad_entry=False,enter_for_dfl=False):
  308. mn = mn or sample_mn[fmt]['mn'].split()
  309. t = self.spawn('mmgen-tool',['mn2hex_interactive','fmt='+fmt,'mn_len=12','print_mn=1'])
  310. from mmgen.mn_entry import mn_entry
  311. mne = mn_entry( cfg, fmt, entry_mode )
  312. t.expect(
  313. 'Type a number.*: ',
  314. ('\n' if enter_for_dfl else str(mne.entry_modes.index(entry_mode)+1)),
  315. regex = True )
  316. t.expect('Using (.+) entry mode',regex=True)
  317. mode = strip_ansi_escapes(t.p.match.group(1)).lower()
  318. assert mode == mne.em.name.lower(), f'{mode} != {mne.em.name.lower()}'
  319. stealth_mnemonic_entry(t,mne,mn,entry_mode=entry_mode,pad_entry=pad_entry)
  320. t.expect(sample_mn[fmt]['hex'])
  321. return t
  322. def _user_seed_entry(self,fmt,usr_rand=False,out_fmt=None,entry_mode='full',mn=None):
  323. wcls = get_wallet_cls(fmt_code=fmt)
  324. wf = os.path.join(ref_dir,f'FE3C6545.{wcls.ext}')
  325. if wcls.base_type == 'mnemonic':
  326. mn = mn or read_from_file(wf).strip().split()
  327. elif wcls.type == 'dieroll':
  328. mn = mn or list(remove_whitespace(read_from_file(wf)))
  329. for idx,val in ((5,'x'),(18,'0'),(30,'7'),(44,'9')):
  330. mn.insert(idx,val)
  331. t = self.spawn('mmgen-walletconv',['-r10','-S','-i',fmt,'-o',out_fmt or fmt])
  332. t.expect(f'{capfirst(wcls.base_type or wcls.type)} type:.*{wcls.mn_type}',regex=True)
  333. t.expect(wcls.choose_seedlen_prompt,'1')
  334. t.expect('(Y/n): ','y')
  335. if wcls.base_type == 'mnemonic':
  336. t.expect('Type a number.*: ','6',regex=True)
  337. t.expect('invalid')
  338. from mmgen.mn_entry import mn_entry
  339. mne = mn_entry( cfg, fmt, entry_mode )
  340. t.expect('Type a number.*: ',str(mne.entry_modes.index(entry_mode)+1),regex=True)
  341. t.expect('Using (.+) entry mode',regex=True)
  342. mode = strip_ansi_escapes(t.p.match.group(1)).lower()
  343. assert mode == mne.em.name.lower(), f'{mode} != {mne.em.name.lower()}'
  344. stealth_mnemonic_entry(t,mne,mn,entry_mode=entry_mode)
  345. elif wcls.type == 'dieroll':
  346. user_dieroll_entry(t,mn)
  347. if usr_rand:
  348. t.expect(wcls.user_entropy_prompt,'y')
  349. t.usr_rand(10)
  350. else:
  351. t.expect(wcls.user_entropy_prompt,'n')
  352. if not usr_rand:
  353. sid_chk = 'FE3C6545'
  354. sid = t.expect_getend(f'Valid {wcls.desc} for Seed ID ')
  355. sid = strip_ansi_escapes(sid.split(',')[0])
  356. assert sid == sid_chk, f'Seed ID mismatch! {sid} != {sid_chk}'
  357. t.expect('to confirm: ','YES\n')
  358. return t
  359. def mnemonic_entry_mmgen_minimal(self):
  360. from mmgen.mn_entry import mn_entry
  361. # erase_chars: '\b\x7f'
  362. m = mn_entry( cfg, 'mmgen', 'minimal' )
  363. np = 2
  364. mn = (
  365. 'z',
  366. 'aa',
  367. '1d2ud',
  368. 'fo{}ot{}#'.format('1' * np, '2' * (m.em.pad_max - np)), # substring of 'football'
  369. 'des1p)%erate\n', # substring of 'desperately'
  370. '#t!(ie',
  371. '!)sto8o',
  372. 'the123m8!%s',
  373. '349t(5)rip',
  374. 'di\b\bdesce',
  375. 'cea',
  376. 'bu\x7f\x7fsuic',
  377. 'app\bpl',
  378. 'wd',
  379. 'busy')
  380. return self._user_seed_entry('words',entry_mode='minimal',mn=mn)
  381. def mnemonic_entry_mmgen(self):
  382. return self._user_seed_entry('words',entry_mode='full')
  383. def mnemonic_entry_bip39(self):
  384. return self._user_seed_entry('bip39',entry_mode='full')
  385. def mnemonic_entry_bip39_short(self):
  386. return self._user_seed_entry('bip39',entry_mode='short')
  387. def mn2hex_interactive_mmgen(self):
  388. return self._mn2hex('mmgen',entry_mode='full')
  389. def mn2hex_interactive_mmgen_fixed(self):
  390. return self._mn2hex('mmgen',entry_mode='fixed')
  391. def mn2hex_interactive_bip39(self):
  392. return self._mn2hex('bip39',entry_mode='full')
  393. def mn2hex_interactive_bip39_short(self):
  394. return self._mn2hex('bip39',entry_mode='short',pad_entry=True)
  395. def mn2hex_interactive_bip39_fixed(self):
  396. return self._mn2hex('bip39',entry_mode='fixed',enter_for_dfl=True)
  397. def mn2hex_interactive_xmr(self):
  398. return self._mn2hex('xmrseed',entry_mode='full')
  399. def mn2hex_interactive_xmr_short(self):
  400. return self._mn2hex('xmrseed',entry_mode='short')
  401. def dieroll_entry(self):
  402. return self._user_seed_entry('dieroll')
  403. def dieroll_entry_usrrand(self):
  404. return self._user_seed_entry('dieroll',usr_rand=True,out_fmt='bip39')