mn_entry.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. mn_entry.py - Mnemonic user entry methods for the MMGen suite
  20. """
  21. import time
  22. from .cfg import *
  23. from .util import msg,msg_r,fmt,fmt_list,capfirst,die,ascii_lowercase
  24. from .term import get_char,get_char_raw
  25. from .color import cyan
  26. _return_chars = '\n\r '
  27. _erase_chars = '\b\x7f'
  28. class MnEntryMode(object):
  29. """
  30. Subclasses must implement:
  31. - pad_max: pad character limit (None if variable)
  32. - ss_len: substring length for automatic entry
  33. - get_word(): get a word from the user and return an index into the wordlist,
  34. or None on failure
  35. """
  36. pad_max_info = """
  37. Up to {pad_max}
  38. pad characters per word are permitted.
  39. """
  40. def __init__(self,mne):
  41. self.pad_max_info = ' ' + self.pad_max_info.lstrip() if self.pad_max else '\n'
  42. self.mne = mne
  43. def get_char(self,s):
  44. did_erase = False
  45. while True:
  46. ch = get_char_raw('',num_bytes=1)
  47. if s and ch in _erase_chars:
  48. s = s[:-1]
  49. did_erase = True
  50. else:
  51. return (ch,s,did_erase)
  52. class MnEntryModeFull(MnEntryMode):
  53. name = 'Full'
  54. choose_info = """
  55. Words must be typed in full and entered with ENTER, SPACE,
  56. or a pad character.
  57. """
  58. prompt_info = """
  59. Use the ENTER or SPACE key to enter each word. A pad character will also
  60. enter a word once you’ve typed {ssl} characters total (including pad chars).
  61. """
  62. pad_max = None
  63. @property
  64. def ss_len(self):
  65. return self.mne.longest_word
  66. def get_word(self,mne):
  67. s,pad = ('', 0)
  68. while True:
  69. ch,s,foo = self.get_char(s)
  70. if ch in _return_chars:
  71. if s:
  72. break
  73. elif ch in ascii_lowercase:
  74. s += ch
  75. else:
  76. pad += 1
  77. if pad + len(s) > self.ss_len:
  78. break
  79. return mne.idx(s,'full')
  80. class MnEntryModeShort(MnEntryMode):
  81. name = 'Short'
  82. choose_info = """
  83. Words are entered automatically once {usl} valid word letters
  84. are typed.
  85. """
  86. prompt_info = """
  87. Each word is entered automatically once {ssl} valid word letters are typed.
  88. """
  89. prompt_info_bip39_add = """
  90. Words shorter than {ssl} letters can be entered with ENTER or SPACE, or by
  91. exceeding the pad character limit.
  92. """
  93. pad_max = 16
  94. def __init__(self,mne):
  95. if mne.wl_id == 'bip39':
  96. self.prompt_info += ' ' + self.prompt_info_bip39_add.strip()
  97. return super().__init__(mne)
  98. @property
  99. def ss_len(self):
  100. return self.mne.uniq_ss_len
  101. def get_word(self,mne):
  102. s,pad = ('', 0)
  103. while True:
  104. ch,s,foo = self.get_char(s)
  105. if ch in _return_chars:
  106. if s:
  107. break
  108. elif ch in ascii_lowercase:
  109. s += ch
  110. if len(s) == self.ss_len:
  111. break
  112. else:
  113. pad += 1
  114. if pad > self.pad_max:
  115. break
  116. return mne.idx(s,'short')
  117. class MnEntryModeFixed(MnEntryMode):
  118. name = 'Fixed'
  119. choose_info = """
  120. Words are entered automatically once exactly {usl} characters
  121. are typed.
  122. """
  123. prompt_info = """
  124. Each word is entered automatically once exactly {ssl} characters are typed.
  125. """
  126. prompt_info_add = ( """
  127. Words shorter than {ssl} letters must be padded to fit.
  128. """, """
  129. {sw}-letter words must be padded with one pad character.
  130. """ )
  131. pad_max = None
  132. def __init__(self,mne):
  133. self.len_diff = mne.uniq_ss_len - mne.shortest_word
  134. self.prompt_info += self.prompt_info_add[self.len_diff==1].lstrip()
  135. return super().__init__(mne)
  136. @property
  137. def ss_len(self):
  138. return self.mne.uniq_ss_len
  139. def get_word(self,mne):
  140. s,pad = ('', 0)
  141. while True:
  142. ch,s,foo = self.get_char(s)
  143. if ch in _return_chars:
  144. if s:
  145. break
  146. elif ch in ascii_lowercase:
  147. s += ch
  148. if len(s) + pad == self.ss_len:
  149. return mne.idx(s,'short')
  150. else:
  151. pad += 1
  152. if pad > self.len_diff:
  153. return None
  154. if len(s) + pad == self.ss_len:
  155. return mne.idx(s,'short')
  156. class MnEntryModeMinimal(MnEntryMode):
  157. name = 'Minimal'
  158. choose_info = """
  159. Words are entered automatically once a minimum number of
  160. letters are typed (the number varies from word to word).
  161. """
  162. prompt_info = """
  163. Each word is entered automatically once the minimum required number of valid
  164. word letters is typed.
  165. If your word is not entered automatically, that means it’s a substring of
  166. another word in the wordlist. Such words must be entered explicitly with
  167. the ENTER or SPACE key, or by exceeding the pad character limit.
  168. """
  169. pad_max = 16
  170. ss_len = None
  171. def get_word(self,mne):
  172. s,pad = ('', 0)
  173. lo,hi = (0, len(mne.wl) - 1)
  174. while True:
  175. ch,s,did_erase = self.get_char(s)
  176. if did_erase:
  177. lo,hi = (0, len(mne.wl) - 1)
  178. if ch in _return_chars:
  179. if s:
  180. return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
  181. elif ch in ascii_lowercase:
  182. s += ch
  183. ret = mne.idx(s,'minimal',lo_idx=lo,hi_idx=hi)
  184. if type(ret) != tuple:
  185. return ret
  186. lo,hi = ret
  187. else:
  188. pad += 1
  189. if pad > self.pad_max:
  190. return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
  191. class MnemonicEntry(object):
  192. prompt_info = {
  193. 'intro': """
  194. You will now be prompted for your {ml}-word seed phrase, one word at a time.
  195. """,
  196. 'pad_info': """
  197. Note that anything you type that’s not a lowercase letter will simply be
  198. ignored. This feature allows you to guard against acoustic side-channel
  199. attacks by padding your keyboard entry with “dead characters”. Pad char-
  200. acters may be typed before, after, or in the middle of words.
  201. """,
  202. }
  203. word_prompt = ('Enter word #{}: ','Incorrect entry. Repeat word #{}: ')
  204. usr_dfl_entry_mode = None
  205. _lw = None
  206. _sw = None
  207. _usl = None
  208. def __init__(self,cfg):
  209. self.cfg = cfg
  210. self.set_dfl_entry_mode()
  211. @property
  212. def longest_word(self):
  213. if not self._lw:
  214. self._lw = max(len(w) for w in self.wl)
  215. return self._lw
  216. @property
  217. def shortest_word(self):
  218. if not self._sw:
  219. self._sw = min(len(w) for w in self.wl)
  220. return self._sw
  221. @property
  222. def uniq_ss_len(self):
  223. if not self._usl:
  224. usl = 0
  225. for i in range(len(self.wl)-1):
  226. w1,w2 = self.wl[i],self.wl[i+1]
  227. while True:
  228. if w1[:usl] == w2[:usl]:
  229. usl += 1
  230. else:
  231. break
  232. self._usl = usl
  233. return self._usl
  234. def idx(self,w,entry_mode,lo_idx=None,hi_idx=None):
  235. """
  236. Return values:
  237. - all modes:
  238. - None: failure (substr not in list)
  239. - idx: success
  240. - minimal mode:
  241. - (lo_idx,hi_idx): non-unique match
  242. """
  243. trunc_len = {
  244. 'full': self.longest_word,
  245. 'short': self.uniq_ss_len,
  246. 'minimal': len(w),
  247. }[entry_mode]
  248. w = w[:trunc_len]
  249. last_idx = len(self.wl) - 1
  250. lo = lo_idx or 0
  251. hi = hi_idx or last_idx
  252. while True:
  253. idx = (hi + lo) // 2
  254. cur_w = self.wl[idx][:trunc_len]
  255. if cur_w == w:
  256. if entry_mode == 'minimal':
  257. if idx > 0 and self.wl[idx-1][:len(w)] == w:
  258. return (lo,hi)
  259. elif idx < last_idx and self.wl[idx+1][:len(w)] == w:
  260. return (lo,hi)
  261. return idx
  262. elif hi <= lo:
  263. return None
  264. elif cur_w > w:
  265. hi = idx - 1
  266. else:
  267. lo = idx + 1
  268. def get_cls_by_entry_mode(self,entry_mode):
  269. import mmgen.mn_entry
  270. return getattr( mmgen.mn_entry, 'MnEntryMode'+capfirst(entry_mode) )
  271. def choose_entry_mode(self):
  272. msg('Choose an entry mode:\n')
  273. em_objs = [self.get_cls_by_entry_mode(entry_mode)(self) for entry_mode in self.entry_modes]
  274. for n,mode in enumerate(em_objs,1):
  275. msg(' {}) {:8} {}'.format(
  276. n,
  277. mode.name + ':',
  278. fmt(mode.choose_info,' '*14).lstrip().format(usl=self.uniq_ss_len),
  279. ))
  280. prompt = f'Type a number, or hit ENTER for the default ({capfirst(self.dfl_entry_mode)}): '
  281. erase = '\r' + ' ' * (len(prompt)+19) + '\r'
  282. while True:
  283. uret = get_char(prompt).strip()
  284. if uret == '':
  285. msg_r(erase)
  286. return self.get_cls_by_entry_mode(self.dfl_entry_mode)(self)
  287. elif uret in [str(i) for i in range(1,len(em_objs)+1)]:
  288. msg_r(erase)
  289. return em_objs[int(uret)-1]
  290. else:
  291. msg_r(f'\b {uret!r}: invalid choice ')
  292. time.sleep(self.cfg.err_disp_timeout)
  293. msg_r(erase)
  294. def get_mnemonic_from_user(self,mn_len,validate=True):
  295. mll = list(self.bconv.seedlen_map_rev)
  296. assert mn_len in mll, f'{mn_len}: invalid mnemonic length (must be one of {mll})'
  297. if self.usr_dfl_entry_mode:
  298. em = self.get_cls_by_entry_mode(self.usr_dfl_entry_mode)(self)
  299. i_add = ' (user-configured)'
  300. else:
  301. em = self.choose_entry_mode()
  302. i_add = '.'
  303. msg('\r' + f'Using {cyan(em.name.upper())} entry mode{i_add}')
  304. self.em = em
  305. if not self.usr_dfl_entry_mode:
  306. msg('\n' + (
  307. fmt(self.prompt_info['intro'])
  308. + '\n'
  309. + fmt(self.prompt_info['pad_info'].rstrip() + em.pad_max_info + em.prompt_info, indent=' ')
  310. ).format(
  311. ml = mn_len,
  312. ssl = em.ss_len,
  313. pad_max = em.pad_max,
  314. sw = self.shortest_word,
  315. ))
  316. clear_line = '\n' if self.cfg.test_suite else '{r}{s}{r}'.format(r='\r',s=' '*40)
  317. idx,idxs = 1,[] # initialize idx to a non-None value
  318. while len(idxs) < mn_len:
  319. msg_r(self.word_prompt[idx is None].format(len(idxs)+1))
  320. idx = em.get_word(self)
  321. msg_r(clear_line)
  322. if idx is None:
  323. time.sleep(0.1)
  324. else:
  325. idxs.append(idx)
  326. words = [self.wl[i] for i in idxs]
  327. if validate:
  328. self.bconv.tohex(words)
  329. self.cfg._util.qmsg(
  330. 'Mnemonic is valid' if self.has_chksum else
  331. 'Mnemonic is well-formed (mnemonic format has no checksum to validate)' )
  332. return ' '.join(words)
  333. @classmethod
  334. def get_cls_by_wordlist(cls,wl):
  335. d = {
  336. 'mmgen': MnemonicEntryMMGen,
  337. 'bip39': MnemonicEntryBIP39,
  338. 'xmrseed': MnemonicEntryMonero,
  339. }
  340. wl = wl.lower()
  341. if wl not in d:
  342. raise ValueError(f'wordlist {wl!r} not recognized (valid choices: {fmt_list(list(d))})')
  343. return d[wl]
  344. def set_dfl_entry_mode(self):
  345. """
  346. In addition to setting the default entry mode for the current wordlist, checks validity
  347. of all user-configured entry modes
  348. """
  349. for k,v in self.cfg.mnemonic_entry_modes.items():
  350. cls = self.get_cls_by_wordlist(k)
  351. if v not in cls.entry_modes:
  352. errmsg = """
  353. Error in cfg file option 'mnemonic_entry_modes':
  354. Entry mode {!r} not recognized for wordlist {!r}:
  355. Valid choices: {}
  356. """.format( v, k, fmt_list(cls.entry_modes) )
  357. die(2, '\n' + fmt(errmsg,indent=' '))
  358. if cls == type(self):
  359. self.usr_dfl_entry_mode = v
  360. class MnemonicEntryMMGen(MnemonicEntry):
  361. wl_id = 'mmgen'
  362. modname = 'baseconv'
  363. entry_modes = ('full','minimal','fixed')
  364. dfl_entry_mode = 'minimal'
  365. has_chksum = False
  366. class MnemonicEntryBIP39(MnemonicEntry):
  367. wl_id = 'bip39'
  368. modname = 'bip39'
  369. entry_modes = ('full','short','fixed')
  370. dfl_entry_mode = 'fixed'
  371. has_chksum = True
  372. class MnemonicEntryMonero(MnemonicEntry):
  373. wl_id = 'xmrseed'
  374. modname = 'xmrseed'
  375. entry_modes = ('full','short')
  376. dfl_entry_mode = 'short'
  377. has_chksum = True
  378. def mn_entry(cfg,wl_id,entry_mode=None):
  379. if wl_id == 'words':
  380. wl_id = 'mmgen'
  381. me = MnemonicEntry.get_cls_by_wordlist(wl_id)(cfg)
  382. import importlib
  383. me.bconv = getattr(importlib.import_module(f'mmgen.{me.modname}'),me.modname)(wl_id)
  384. me.wl = me.bconv.digits
  385. if entry_mode:
  386. import mmgen.mn_entry
  387. me.em = getattr( mmgen.mn_entry, 'MnEntryMode'+capfirst(entry_mode) )(me)
  388. return me