mn_entry.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2022 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 .globalvars import *
  23. from .util import msg,msg_r,qmsg,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. def mn_entry(wl_id,entry_mode=None):
  192. if wl_id == 'words':
  193. wl_id = 'mmgen'
  194. me = MnemonicEntry.get_cls_by_wordlist(wl_id)
  195. import importlib
  196. me.bconv = getattr(importlib.import_module(f'mmgen.{me.modname}'),me.modname)(wl_id)
  197. me.wl = me.bconv.digits
  198. obj = me()
  199. if entry_mode:
  200. import mmgen.mn_entry
  201. obj.em = getattr( mmgen.mn_entry, 'MnEntryMode'+capfirst(entry_mode) )(obj)
  202. return obj
  203. class MnemonicEntry(object):
  204. prompt_info = {
  205. 'intro': """
  206. You will now be prompted for your {ml}-word seed phrase, one word at a time.
  207. """,
  208. 'pad_info': """
  209. Note that anything you type that’s not a lowercase letter will simply be
  210. ignored. This feature allows you to guard against acoustic side-channel
  211. attacks by padding your keyboard entry with “dead characters”. Pad char-
  212. acters may be typed before, after, or in the middle of words.
  213. """,
  214. }
  215. word_prompt = ('Enter word #{}: ','Incorrect entry. Repeat word #{}: ')
  216. usr_dfl_entry_mode = None
  217. _lw = None
  218. _sw = None
  219. _usl = None
  220. @property
  221. def longest_word(self):
  222. if not self._lw:
  223. self._lw = max(len(w) for w in self.wl)
  224. return self._lw
  225. @property
  226. def shortest_word(self):
  227. if not self._sw:
  228. self._sw = min(len(w) for w in self.wl)
  229. return self._sw
  230. @property
  231. def uniq_ss_len(self):
  232. if not self._usl:
  233. usl = 0
  234. for i in range(len(self.wl)-1):
  235. w1,w2 = self.wl[i],self.wl[i+1]
  236. while True:
  237. if w1[:usl] == w2[:usl]:
  238. usl += 1
  239. else:
  240. break
  241. self._usl = usl
  242. return self._usl
  243. def idx(self,w,entry_mode,lo_idx=None,hi_idx=None):
  244. """
  245. Return values:
  246. - all modes:
  247. - None: failure (substr not in list)
  248. - idx: success
  249. - minimal mode:
  250. - (lo_idx,hi_idx): non-unique match
  251. """
  252. trunc_len = {
  253. 'full': self.longest_word,
  254. 'short': self.uniq_ss_len,
  255. 'minimal': len(w),
  256. }[entry_mode]
  257. w = w[:trunc_len]
  258. last_idx = len(self.wl) - 1
  259. lo = lo_idx or 0
  260. hi = hi_idx or last_idx
  261. while True:
  262. idx = (hi + lo) // 2
  263. cur_w = self.wl[idx][:trunc_len]
  264. if cur_w == w:
  265. if entry_mode == 'minimal':
  266. if idx > 0 and self.wl[idx-1][:len(w)] == w:
  267. return (lo,hi)
  268. elif idx < last_idx and self.wl[idx+1][:len(w)] == w:
  269. return (lo,hi)
  270. return idx
  271. elif hi <= lo:
  272. return None
  273. elif cur_w > w:
  274. hi = idx - 1
  275. else:
  276. lo = idx + 1
  277. def get_cls_by_entry_mode(self,entry_mode):
  278. import mmgen.mn_entry
  279. return getattr( mmgen.mn_entry, 'MnEntryMode'+capfirst(entry_mode) )
  280. def choose_entry_mode(self):
  281. msg('Choose an entry mode:\n')
  282. em_objs = [self.get_cls_by_entry_mode(entry_mode)(self) for entry_mode in self.entry_modes]
  283. for n,mode in enumerate(em_objs,1):
  284. msg(' {}) {:8} {}'.format(
  285. n,
  286. mode.name + ':',
  287. fmt(mode.choose_info,' '*14).lstrip().format(usl=self.uniq_ss_len),
  288. ))
  289. prompt = f'Type a number, or hit ENTER for the default ({capfirst(self.dfl_entry_mode)}): '
  290. erase = '\r' + ' ' * (len(prompt)+19) + '\r'
  291. while True:
  292. uret = get_char(prompt).strip()
  293. if uret == '':
  294. msg_r(erase)
  295. return self.get_cls_by_entry_mode(self.dfl_entry_mode)(self)
  296. elif uret in [str(i) for i in range(1,len(em_objs)+1)]:
  297. msg_r(erase)
  298. return em_objs[int(uret)-1]
  299. else:
  300. msg_r(f'\b {uret!r}: invalid choice ')
  301. time.sleep(g.err_disp_timeout)
  302. msg_r(erase)
  303. def get_mnemonic_from_user(self,mn_len,validate=True):
  304. mll = list(self.bconv.seedlen_map_rev)
  305. assert mn_len in mll, f'{mn_len}: invalid mnemonic length (must be one of {mll})'
  306. if self.usr_dfl_entry_mode:
  307. em = self.get_cls_by_entry_mode(self.usr_dfl_entry_mode)(self)
  308. i_add = ' (user-configured)'
  309. else:
  310. em = self.choose_entry_mode()
  311. i_add = '.'
  312. msg('\r' + f'Using {cyan(em.name.upper())} entry mode{i_add}')
  313. self.em = em
  314. if not self.usr_dfl_entry_mode:
  315. msg('\n' + (
  316. fmt(self.prompt_info['intro'])
  317. + '\n'
  318. + fmt(self.prompt_info['pad_info'].rstrip() + em.pad_max_info + em.prompt_info, indent=' ')
  319. ).format(
  320. ml = mn_len,
  321. ssl = em.ss_len,
  322. pad_max = em.pad_max,
  323. sw = self.shortest_word,
  324. ))
  325. clear_line = '\n' if g.test_suite else '{r}{s}{r}'.format(r='\r',s=' '*40)
  326. idx,idxs = 1,[] # initialize idx to a non-None value
  327. while len(idxs) < mn_len:
  328. msg_r(self.word_prompt[idx is None].format(len(idxs)+1))
  329. idx = em.get_word(self)
  330. msg_r(clear_line)
  331. if idx is None:
  332. time.sleep(0.1)
  333. else:
  334. idxs.append(idx)
  335. words = [self.wl[i] for i in idxs]
  336. if validate:
  337. self.bconv.tohex(words)
  338. if self.has_chksum:
  339. qmsg('Mnemonic is valid')
  340. else:
  341. qmsg('Mnemonic is well-formed (mnemonic format has no checksum to validate)')
  342. return ' '.join(words)
  343. @classmethod
  344. def get_cls_by_wordlist(cls,wl):
  345. d = {
  346. 'mmgen': MnemonicEntryMMGen,
  347. 'bip39': MnemonicEntryBIP39,
  348. 'xmrseed': MnemonicEntryMonero,
  349. }
  350. wl = wl.lower()
  351. if wl not in d:
  352. raise ValueError(f'wordlist {wl!r} not recognized (valid choices: {fmt_list(list(d))})')
  353. return d[wl]
  354. @classmethod
  355. def get_cfg_vars(cls):
  356. for k,v in g.mnemonic_entry_modes.items():
  357. tcls = cls.get_cls_by_wordlist(k)
  358. if v not in tcls.entry_modes:
  359. raise ValueError(
  360. f'entry mode {v!r} not recognized for wordlist {k!r}:' +
  361. f'\n (valid choices: {fmt_list(tcls.entry_modes)})' )
  362. tcls.usr_dfl_entry_mode = v
  363. class MnemonicEntryMMGen(MnemonicEntry):
  364. wl_id = 'mmgen'
  365. modname = 'baseconv'
  366. entry_modes = ('full','minimal','fixed')
  367. dfl_entry_mode = 'minimal'
  368. has_chksum = False
  369. class MnemonicEntryBIP39(MnemonicEntry):
  370. wl_id = 'bip39'
  371. modname = 'bip39'
  372. entry_modes = ('full','short','fixed')
  373. dfl_entry_mode = 'fixed'
  374. has_chksum = True
  375. class MnemonicEntryMonero(MnemonicEntry):
  376. wl_id = 'xmrseed'
  377. modname = 'xmrseed'
  378. entry_modes = ('full','short')
  379. dfl_entry_mode = 'short'
  380. has_chksum = True
  381. try:
  382. MnemonicEntry.get_cfg_vars()
  383. except Exception as e:
  384. die(2, f"Error in cfg file option 'mnemonic_entry_modes':\n {e.args[0]}")