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
  24. from .term import get_char,get_char_raw
  25. from .color import cyan
  26. from string import ascii_lowercase as _word_chars
  27. _return_chars = '\n\r '
  28. _erase_chars = '\b\x7f'
  29. class MnEntryMode(object):
  30. """
  31. Subclasses must implement:
  32. - pad_max: pad character limit (None if variable)
  33. - ss_len: substring length for automatic entry
  34. - get_word(): get a word from the user and return an index into the wordlist,
  35. or None on failure
  36. """
  37. pad_max_info = """
  38. Up to {pad_max}
  39. pad characters per word are permitted.
  40. """
  41. def __init__(self,mne):
  42. self.pad_max_info = ' ' + self.pad_max_info.lstrip() if self.pad_max else '\n'
  43. self.mne = mne
  44. def get_char(self,s):
  45. did_erase = False
  46. while True:
  47. ch = get_char_raw('',num_chars=1)
  48. if s and ch in _erase_chars:
  49. s = s[:-1]
  50. did_erase = True
  51. else:
  52. return (ch,s,did_erase)
  53. class MnEntryModeFull(MnEntryMode):
  54. name = 'Full'
  55. choose_info = """
  56. Words must be typed in full and entered with ENTER, SPACE,
  57. or a pad character.
  58. """
  59. prompt_info = """
  60. Use the ENTER or SPACE key to enter each word. A pad character will also
  61. enter a word once you’ve typed {ssl} characters total (including pad chars).
  62. """
  63. pad_max = None
  64. @property
  65. def ss_len(self):
  66. return self.mne.longest_word
  67. def get_word(self,mne):
  68. s,pad = ('', 0)
  69. while True:
  70. ch,s,foo = self.get_char(s)
  71. if ch in _return_chars:
  72. if s:
  73. break
  74. elif ch in _word_chars:
  75. s += ch
  76. else:
  77. pad += 1
  78. if pad + len(s) > self.ss_len:
  79. break
  80. return mne.idx(s,'full')
  81. class MnEntryModeShort(MnEntryMode):
  82. name = 'Short'
  83. choose_info = """
  84. Words are entered automatically once {usl} valid word letters
  85. are typed.
  86. """
  87. prompt_info = """
  88. Each word is entered automatically once {ssl} valid word letters are typed.
  89. """
  90. prompt_info_bip39_add = """
  91. Words shorter than {ssl} letters can be entered with ENTER or SPACE, or by
  92. exceeding the pad character limit.
  93. """
  94. pad_max = 16
  95. def __init__(self,mne):
  96. if mne.wl_id == 'bip39':
  97. self.prompt_info += ' ' + self.prompt_info_bip39_add.strip()
  98. return super().__init__(mne)
  99. @property
  100. def ss_len(self):
  101. return self.mne.uniq_ss_len
  102. def get_word(self,mne):
  103. s,pad = ('', 0)
  104. while True:
  105. ch,s,foo = self.get_char(s)
  106. if ch in _return_chars:
  107. if s:
  108. break
  109. elif ch in _word_chars:
  110. s += ch
  111. if len(s) == self.ss_len:
  112. break
  113. else:
  114. pad += 1
  115. if pad > self.pad_max:
  116. break
  117. return mne.idx(s,'short')
  118. class MnEntryModeFixed(MnEntryMode):
  119. name = 'Fixed'
  120. choose_info = """
  121. Words are entered automatically once exactly {usl} characters
  122. are typed.
  123. """
  124. prompt_info = """
  125. Each word is entered automatically once exactly {ssl} characters are typed.
  126. """
  127. prompt_info_add = ( """
  128. Words shorter than {ssl} letters must be padded to fit.
  129. """, """
  130. {sw}-letter words must be padded with one pad character.
  131. """ )
  132. pad_max = None
  133. def __init__(self,mne):
  134. self.len_diff = mne.uniq_ss_len - mne.shortest_word
  135. self.prompt_info += self.prompt_info_add[self.len_diff==1].lstrip()
  136. return super().__init__(mne)
  137. @property
  138. def ss_len(self):
  139. return self.mne.uniq_ss_len
  140. def get_word(self,mne):
  141. s,pad = ('', 0)
  142. while True:
  143. ch,s,foo = self.get_char(s)
  144. if ch in _return_chars:
  145. if s:
  146. break
  147. elif ch in _word_chars:
  148. s += ch
  149. if len(s) + pad == self.ss_len:
  150. return mne.idx(s,'short')
  151. else:
  152. pad += 1
  153. if pad > self.len_diff:
  154. return None
  155. if len(s) + pad == self.ss_len:
  156. return mne.idx(s,'short')
  157. class MnEntryModeMinimal(MnEntryMode):
  158. name = 'Minimal'
  159. choose_info = """
  160. Words are entered automatically once a minimum number of
  161. letters are typed (the number varies from word to word).
  162. """
  163. prompt_info = """
  164. Each word is entered automatically once the minimum required number of valid
  165. word letters is typed.
  166. If your word is not entered automatically, that means it’s a substring of
  167. another word in the wordlist. Such words must be entered explicitly with
  168. the ENTER or SPACE key, or by exceeding the pad character limit.
  169. """
  170. pad_max = 16
  171. ss_len = None
  172. def get_word(self,mne):
  173. s,pad = ('', 0)
  174. lo,hi = (0, len(mne.wl) - 1)
  175. while True:
  176. ch,s,did_erase = self.get_char(s)
  177. if did_erase:
  178. lo,hi = (0, len(mne.wl) - 1)
  179. if ch in _return_chars:
  180. if s:
  181. return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
  182. elif ch in _word_chars:
  183. s += ch
  184. ret = mne.idx(s,'minimal',lo_idx=lo,hi_idx=hi)
  185. if type(ret) != tuple:
  186. return ret
  187. lo,hi = ret
  188. else:
  189. pad += 1
  190. if pad > self.pad_max:
  191. return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
  192. def mn_entry(wl_id,entry_mode=None):
  193. if wl_id == 'words':
  194. wl_id = 'mmgen'
  195. me = MnemonicEntry.get_cls_by_wordlist(wl_id)
  196. import importlib
  197. me.conv_cls = getattr(importlib.import_module(f'mmgen.{me.modname}'),me.modname)
  198. me.conv_cls.init_mn(wl_id)
  199. me.wl = me.conv_cls.digits[wl_id]
  200. obj = me()
  201. if entry_mode:
  202. obj.em = globals()['MnEntryMode'+capfirst(entry_mode)](obj)
  203. return obj
  204. class MnemonicEntry(object):
  205. prompt_info = {
  206. 'intro': """
  207. You will now be prompted for your {ml}-word seed phrase, one word at a time.
  208. """,
  209. 'pad_info': """
  210. Note that anything you type that’s not a lowercase letter will simply be
  211. ignored. This feature allows you to guard against acoustic side-channel
  212. attacks by padding your keyboard entry with “dead characters”. Pad char-
  213. acters may be typed before, after, or in the middle of words.
  214. """,
  215. }
  216. word_prompt = ('Enter word #{}: ','Incorrect entry. Repeat word #{}: ')
  217. usr_dfl_entry_mode = None
  218. _lw = None
  219. _sw = None
  220. _usl = None
  221. @property
  222. def longest_word(self):
  223. if not self._lw:
  224. self._lw = max(len(w) for w in self.wl)
  225. return self._lw
  226. @property
  227. def shortest_word(self):
  228. if not self._sw:
  229. self._sw = min(len(w) for w in self.wl)
  230. return self._sw
  231. @property
  232. def uniq_ss_len(self):
  233. if not self._usl:
  234. usl = 0
  235. for i in range(len(self.wl)-1):
  236. w1,w2 = self.wl[i],self.wl[i+1]
  237. while True:
  238. if w1[:usl] == w2[:usl]:
  239. usl += 1
  240. else:
  241. break
  242. self._usl = usl
  243. return self._usl
  244. def idx(self,w,entry_mode,lo_idx=None,hi_idx=None):
  245. """
  246. Return values:
  247. - all modes:
  248. - None: failure (substr not in list)
  249. - idx: success
  250. - minimal mode:
  251. - (lo_idx,hi_idx): non-unique match
  252. """
  253. trunc_len = {
  254. 'full': self.longest_word,
  255. 'short': self.uniq_ss_len,
  256. 'minimal': len(w),
  257. }[entry_mode]
  258. w = w[:trunc_len]
  259. last_idx = len(self.wl) - 1
  260. lo = lo_idx or 0
  261. hi = hi_idx or last_idx
  262. while True:
  263. idx = (hi + lo) // 2
  264. cur_w = self.wl[idx][:trunc_len]
  265. if cur_w == w:
  266. if entry_mode == 'minimal':
  267. if idx > 0 and self.wl[idx-1][:len(w)] == w:
  268. return (lo,hi)
  269. elif idx < last_idx and self.wl[idx+1][:len(w)] == w:
  270. return (lo,hi)
  271. return idx
  272. elif hi <= lo:
  273. return None
  274. elif cur_w > w:
  275. hi = idx - 1
  276. else:
  277. lo = idx + 1
  278. def get_cls_by_entry_mode(self,entry_mode):
  279. return globals()['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.conv_cls.seedlen_map_rev[self.wl_id])
  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.conv_cls.tohex(words,self.wl_id)
  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 options: {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 options: {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 = 'baseconv'
  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]}")