mn_entry.py 11 KB

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