|
@@ -0,0 +1,419 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+#
|
|
|
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
|
|
|
+# Copyright (C)2013-2020 The MMGen Project <mmgen@tuta.io>
|
|
|
+#
|
|
|
+# This program is free software: you can redistribute it and/or modify
|
|
|
+# it under the terms of the GNU General Public License as published by
|
|
|
+# the Free Software Foundation, either version 3 of the License, or
|
|
|
+# (at your option) any later version.
|
|
|
+#
|
|
|
+# This program is distributed in the hope that it will be useful,
|
|
|
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
+# GNU General Public License for more details.
|
|
|
+#
|
|
|
+# You should have received a copy of the GNU General Public License
|
|
|
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
+
|
|
|
+"""
|
|
|
+mn_entry.py - Mnemonic user entry methods for the MMGen suite
|
|
|
+"""
|
|
|
+
|
|
|
+import time
|
|
|
+
|
|
|
+from mmgen.globalvars import *
|
|
|
+from mmgen.util import msg,msg_r,qmsg,fmt,fmt_list,capfirst,die
|
|
|
+from mmgen.term import get_char,get_char_raw
|
|
|
+from mmgen.color import cyan
|
|
|
+
|
|
|
+from string import ascii_lowercase as _word_chars
|
|
|
+_return_chars = '\n\r '
|
|
|
+_erase_chars = '\b\x7f'
|
|
|
+
|
|
|
+class MnEntryMode(object):
|
|
|
+ """
|
|
|
+ Subclasses must implement:
|
|
|
+ - pad_max: pad character limit (None if variable)
|
|
|
+ - ss_len: substring length for automatic entry
|
|
|
+ - get_word(): get a word from the user and return an index into the wordlist,
|
|
|
+ or None on failure
|
|
|
+ """
|
|
|
+
|
|
|
+ pad_max_info = """
|
|
|
+ Up to {pad_max}
|
|
|
+ pad characters per word are permitted.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self,mne):
|
|
|
+ self.pad_max_info = ' ' + self.pad_max_info.lstrip() if self.pad_max else '\n'
|
|
|
+ self.mne = mne
|
|
|
+
|
|
|
+ def get_char(self,s):
|
|
|
+ did_erase = False
|
|
|
+ while True:
|
|
|
+ ch = get_char_raw('',num_chars=1).decode()
|
|
|
+ if s and ch in _erase_chars:
|
|
|
+ s = s[:-1]
|
|
|
+ did_erase = True
|
|
|
+ else:
|
|
|
+ return (ch,s,did_erase)
|
|
|
+
|
|
|
+class MnEntryModeFull(MnEntryMode):
|
|
|
+ name = 'Full'
|
|
|
+ choose_info = """
|
|
|
+ Words must be typed in full and entered with ENTER, SPACE,
|
|
|
+ or a pad character.
|
|
|
+ """
|
|
|
+ prompt_info = """
|
|
|
+ Use the ENTER or SPACE key to enter each word. A pad character will also
|
|
|
+ enter a word once you’ve typed {ssl} characters total (including pad chars).
|
|
|
+ """
|
|
|
+ pad_max = None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def ss_len(self):
|
|
|
+ return self.mne.longest_word
|
|
|
+
|
|
|
+ def get_word(self,mne):
|
|
|
+ s,pad = ('', 0)
|
|
|
+ while True:
|
|
|
+ ch,s,foo = self.get_char(s)
|
|
|
+ if ch in _return_chars:
|
|
|
+ if s:
|
|
|
+ break
|
|
|
+ elif ch in _word_chars:
|
|
|
+ s += ch
|
|
|
+ else:
|
|
|
+ pad += 1
|
|
|
+ if pad + len(s) > self.ss_len:
|
|
|
+ break
|
|
|
+
|
|
|
+ return mne.idx(s,'full')
|
|
|
+
|
|
|
+class MnEntryModeShort(MnEntryMode):
|
|
|
+ name = 'Short'
|
|
|
+ choose_info = """
|
|
|
+ Words are entered automatically once {usl} valid word letters
|
|
|
+ are typed.
|
|
|
+ """
|
|
|
+ prompt_info = """
|
|
|
+ Each word is entered automatically once {ssl} valid word letters are typed.
|
|
|
+ """
|
|
|
+ prompt_info_bip39_add = """
|
|
|
+ Words shorter than {ssl} letters can be entered with ENTER or SPACE, or by
|
|
|
+ exceeding the pad character limit.
|
|
|
+ """
|
|
|
+ pad_max = 16
|
|
|
+
|
|
|
+ def __init__(self,mne):
|
|
|
+ if mne.wl_id == 'bip39':
|
|
|
+ self.prompt_info += ' ' + self.prompt_info_bip39_add.strip()
|
|
|
+ return super().__init__(mne)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def ss_len(self):
|
|
|
+ return self.mne.uniq_ss_len
|
|
|
+
|
|
|
+ def get_word(self,mne):
|
|
|
+ s,pad = ('', 0)
|
|
|
+ while True:
|
|
|
+ ch,s,foo = self.get_char(s)
|
|
|
+ if ch in _return_chars:
|
|
|
+ if s:
|
|
|
+ break
|
|
|
+ elif ch in _word_chars:
|
|
|
+ s += ch
|
|
|
+ if len(s) == self.ss_len:
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ pad += 1
|
|
|
+ if pad > self.pad_max:
|
|
|
+ break
|
|
|
+
|
|
|
+ return mne.idx(s,'short')
|
|
|
+
|
|
|
+class MnEntryModeFixed(MnEntryMode):
|
|
|
+ name = 'Fixed'
|
|
|
+ choose_info = """
|
|
|
+ Words are entered automatically once exactly {usl} characters
|
|
|
+ are typed.
|
|
|
+ """
|
|
|
+ prompt_info = """
|
|
|
+ Each word is entered automatically once exactly {ssl} characters are typed.
|
|
|
+ """
|
|
|
+ prompt_info_add = ( """
|
|
|
+ Words shorter than {ssl} letters must be padded to fit.
|
|
|
+ """, """
|
|
|
+ {sw}-letter words must be padded with one pad character.
|
|
|
+ """ )
|
|
|
+ pad_max = None
|
|
|
+
|
|
|
+ def __init__(self,mne):
|
|
|
+ self.len_diff = mne.uniq_ss_len - mne.shortest_word
|
|
|
+ self.prompt_info += self.prompt_info_add[self.len_diff==1].lstrip()
|
|
|
+ return super().__init__(mne)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def ss_len(self):
|
|
|
+ return self.mne.uniq_ss_len
|
|
|
+
|
|
|
+ def get_word(self,mne):
|
|
|
+ s,pad = ('', 0)
|
|
|
+ while True:
|
|
|
+ ch,s,foo = self.get_char(s)
|
|
|
+ if ch in _return_chars:
|
|
|
+ if s:
|
|
|
+ break
|
|
|
+ elif ch in _word_chars:
|
|
|
+ s += ch
|
|
|
+ if len(s) + pad == self.ss_len:
|
|
|
+ return mne.idx(s,'short')
|
|
|
+ else:
|
|
|
+ pad += 1
|
|
|
+ if pad > self.len_diff:
|
|
|
+ return None
|
|
|
+ if len(s) + pad == self.ss_len:
|
|
|
+ return mne.idx(s,'short')
|
|
|
+
|
|
|
+class MnEntryModeMinimal(MnEntryMode):
|
|
|
+ name = 'Minimal'
|
|
|
+ choose_info = """
|
|
|
+ Words are entered automatically once a minimum number of
|
|
|
+ letters are typed (the number varies from word to word).
|
|
|
+ """
|
|
|
+ prompt_info = """
|
|
|
+ Each word is entered automatically once the minimum required number of valid
|
|
|
+ word letters is typed.
|
|
|
+
|
|
|
+ If your word is not entered automatically, that means it’s a substring of
|
|
|
+ another word in the wordlist. Such words must be entered explicitly with
|
|
|
+ the ENTER or SPACE key, or by exceeding the pad character limit.
|
|
|
+ """
|
|
|
+ pad_max = 16
|
|
|
+ ss_len = None
|
|
|
+
|
|
|
+ def get_word(self,mne):
|
|
|
+ s,pad = ('', 0)
|
|
|
+ lo,hi = (0, len(mne.wl) - 1)
|
|
|
+ while True:
|
|
|
+ ch,s,did_erase = self.get_char(s)
|
|
|
+ if did_erase:
|
|
|
+ lo,hi = (0, len(mne.wl) - 1)
|
|
|
+ if ch in _return_chars:
|
|
|
+ if s:
|
|
|
+ return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
|
|
|
+ elif ch in _word_chars:
|
|
|
+ s += ch
|
|
|
+ ret = mne.idx(s,'minimal',lo_idx=lo,hi_idx=hi)
|
|
|
+ if type(ret) != tuple:
|
|
|
+ return ret
|
|
|
+ lo,hi = ret
|
|
|
+ else:
|
|
|
+ pad += 1
|
|
|
+ if pad > self.pad_max:
|
|
|
+ return mne.idx(s,'full',lo_idx=lo,hi_idx=hi)
|
|
|
+
|
|
|
+def mn_entry(wl_id,entry_mode=None):
|
|
|
+ if wl_id == 'words':
|
|
|
+ wl_id = 'mmgen'
|
|
|
+ me = MnemonicEntry.get_cls_by_wordlist(wl_id)
|
|
|
+ import importlib
|
|
|
+ me.conv_cls = getattr(importlib.import_module('mmgen.{}'.format(me.modname)),me.modname)
|
|
|
+ me.conv_cls.init_mn(wl_id)
|
|
|
+ me.wl = me.conv_cls.digits[wl_id]
|
|
|
+ obj = me()
|
|
|
+ if entry_mode:
|
|
|
+ obj.em = globals()['MnEntryMode'+capfirst(entry_mode)](obj)
|
|
|
+ return obj
|
|
|
+
|
|
|
+class MnemonicEntry(object):
|
|
|
+
|
|
|
+ prompt_info = {
|
|
|
+ 'intro': """
|
|
|
+ You will now be prompted for your {ml}-word seed phrase, one word at a time.
|
|
|
+ """,
|
|
|
+ 'pad_info': """
|
|
|
+ Note that anything you type that’s not a lowercase letter will simply be
|
|
|
+ ignored. This feature allows you to guard against acoustic side-channel
|
|
|
+ attacks by padding your keyboard entry with “dead characters”. Pad char-
|
|
|
+ acters may be typed before, after, or in the middle of words.
|
|
|
+ """,
|
|
|
+ }
|
|
|
+ word_prompt = ('Enter word #{}: ','Incorrect entry. Repeat word #{}: ')
|
|
|
+ dfl_entry_mode = None
|
|
|
+ _lw = None
|
|
|
+ _sw = None
|
|
|
+ _usl = None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def longest_word(self):
|
|
|
+ if not self._lw:
|
|
|
+ self._lw = max(len(w) for w in self.wl)
|
|
|
+ return self._lw
|
|
|
+
|
|
|
+ @property
|
|
|
+ def shortest_word(self):
|
|
|
+ if not self._sw:
|
|
|
+ self._sw = min(len(w) for w in self.wl)
|
|
|
+ return self._sw
|
|
|
+
|
|
|
+ @property
|
|
|
+ def uniq_ss_len(self):
|
|
|
+ if not self._usl:
|
|
|
+ usl = 0
|
|
|
+ for i in range(len(self.wl)-1):
|
|
|
+ w1,w2 = self.wl[i],self.wl[i+1]
|
|
|
+ while True:
|
|
|
+ if w1[:usl] == w2[:usl]:
|
|
|
+ usl += 1
|
|
|
+ else:
|
|
|
+ break
|
|
|
+ self._usl = usl
|
|
|
+ return self._usl
|
|
|
+
|
|
|
+ def idx(self,w,entry_mode,lo_idx=None,hi_idx=None):
|
|
|
+ """
|
|
|
+ Return values:
|
|
|
+ - all modes:
|
|
|
+ - None: failure (substr not in list)
|
|
|
+ - idx: success
|
|
|
+ - minimal mode:
|
|
|
+ - (lo_idx,hi_idx): non-unique match
|
|
|
+ """
|
|
|
+ trunc_len = {
|
|
|
+ 'full': self.longest_word,
|
|
|
+ 'short': self.uniq_ss_len,
|
|
|
+ 'minimal': len(w),
|
|
|
+ }[entry_mode]
|
|
|
+ w = w[:trunc_len]
|
|
|
+ last_idx = len(self.wl) - 1
|
|
|
+ lo = lo_idx or 0
|
|
|
+ hi = hi_idx or last_idx
|
|
|
+ while True:
|
|
|
+ idx = (hi + lo) // 2
|
|
|
+ cur_w = self.wl[idx][:trunc_len]
|
|
|
+ if cur_w == w:
|
|
|
+ if entry_mode == 'minimal':
|
|
|
+ if idx > 0 and self.wl[idx-1][:len(w)] == w:
|
|
|
+ return (lo,hi)
|
|
|
+ elif idx < last_idx and self.wl[idx+1][:len(w)] == w:
|
|
|
+ return (lo,hi)
|
|
|
+ return idx
|
|
|
+ elif hi <= lo:
|
|
|
+ return None
|
|
|
+ elif cur_w > w:
|
|
|
+ hi = idx - 1
|
|
|
+ else:
|
|
|
+ lo = idx + 1
|
|
|
+
|
|
|
+ def get_cls_by_entry_mode(self,entry_mode):
|
|
|
+ return globals()['MnEntryMode'+capfirst(entry_mode)]
|
|
|
+
|
|
|
+ def choose_entry_mode(self):
|
|
|
+ msg('Choose an entry mode:\n')
|
|
|
+ em_objs = [self.get_cls_by_entry_mode(entry_mode)(self) for entry_mode in self.entry_modes]
|
|
|
+ for n,mode in enumerate(em_objs,1):
|
|
|
+ msg(' {}) {:8} {}'.format(
|
|
|
+ n,
|
|
|
+ mode.name + ':',
|
|
|
+ fmt(mode.choose_info,' '*14).lstrip().format(usl=self.uniq_ss_len),
|
|
|
+ ))
|
|
|
+ while True:
|
|
|
+ uret = get_char('Entry mode: ').decode()
|
|
|
+ if uret in [str(i) for i in range(1,len(em_objs)+1)]:
|
|
|
+ return em_objs[int(uret)-1]
|
|
|
+ else:
|
|
|
+ msg_r('\b {!r}: invalid choice '.format(uret))
|
|
|
+ time.sleep(g.err_disp_timeout)
|
|
|
+ msg_r('\r'+' '*38+'\r')
|
|
|
+
|
|
|
+ def get_mnemonic_from_user(self,mn_len,validate=True):
|
|
|
+ mll = list(self.conv_cls.seedlen_map_rev[self.wl_id])
|
|
|
+ assert mn_len in mll, '{}: invalid mnemonic length (must be one of {})'.format(mn_len,mll)
|
|
|
+
|
|
|
+ if self.dfl_entry_mode:
|
|
|
+ em = self.get_cls_by_entry_mode(self.dfl_entry_mode)(self)
|
|
|
+ i_add = ' (user-configured)'
|
|
|
+ else:
|
|
|
+ em = self.choose_entry_mode()
|
|
|
+ i_add = '.'
|
|
|
+
|
|
|
+ msg('\r' + 'Using {} entry mode{}'.format(cyan(em.name.upper()),i_add))
|
|
|
+ self.em = em
|
|
|
+
|
|
|
+ if not self.dfl_entry_mode:
|
|
|
+ m = (
|
|
|
+ fmt(self.prompt_info['intro'])
|
|
|
+ + '\n'
|
|
|
+ + fmt(self.prompt_info['pad_info'].rstrip() + em.pad_max_info + em.prompt_info, indent=' ')
|
|
|
+ )
|
|
|
+ msg('\n' + m.format(
|
|
|
+ ml = mn_len,
|
|
|
+ ssl = em.ss_len,
|
|
|
+ pad_max = em.pad_max,
|
|
|
+ sw = self.shortest_word,
|
|
|
+ ))
|
|
|
+
|
|
|
+ clear_line = '\n' if g.test_suite else '{r}{s}{r}'.format(r='\r',s=' '*40)
|
|
|
+ idx,idxs = 1,[] # initialize idx to a non-None value
|
|
|
+
|
|
|
+ while len(idxs) < mn_len:
|
|
|
+ msg_r(self.word_prompt[idx is None].format(len(idxs)+1))
|
|
|
+ idx = em.get_word(self)
|
|
|
+ msg_r(clear_line)
|
|
|
+ if idx is None:
|
|
|
+ time.sleep(0.1)
|
|
|
+ else:
|
|
|
+ idxs.append(idx)
|
|
|
+
|
|
|
+ words = [self.wl[i] for i in idxs]
|
|
|
+
|
|
|
+ if validate:
|
|
|
+ self.conv_cls.tohex(words,self.wl_id)
|
|
|
+ qmsg('Mnemonic is valid')
|
|
|
+
|
|
|
+ return ' '.join(words)
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def get_cls_by_wordlist(cls,wl):
|
|
|
+ d = {
|
|
|
+ 'mmgen': MnemonicEntryMMGen,
|
|
|
+ 'bip39': MnemonicEntryBIP39,
|
|
|
+ 'xmrseed': MnemonicEntryMonero,
|
|
|
+ }
|
|
|
+ wl = wl.lower()
|
|
|
+ if wl not in d:
|
|
|
+ m = 'wordlist {!r} not recognized (valid options: {})'
|
|
|
+ raise ValueError(m.format(wl,fmt_list(list(d))))
|
|
|
+ return d[wl]
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def get_cfg_vars(cls):
|
|
|
+ for k,v in g.mnemonic_entry_modes.items():
|
|
|
+ tcls = cls.get_cls_by_wordlist(k)
|
|
|
+ if v not in tcls.entry_modes:
|
|
|
+ m = 'entry mode {!r} not recognized for wordlist {!r}:\n (valid options: {})'
|
|
|
+ raise ValueError(m.format(v,k,fmt_list(tcls.entry_modes)))
|
|
|
+ tcls.dfl_entry_mode = v
|
|
|
+
|
|
|
+class MnemonicEntryMMGen(MnemonicEntry):
|
|
|
+ wl_id = 'mmgen'
|
|
|
+ modname = 'baseconv'
|
|
|
+ entry_modes = ('full','minimal','fixed')
|
|
|
+
|
|
|
+class MnemonicEntryBIP39(MnemonicEntry):
|
|
|
+ wl_id = 'bip39'
|
|
|
+ modname = 'bip39'
|
|
|
+ entry_modes = ('full','short','fixed')
|
|
|
+
|
|
|
+class MnemonicEntryMonero(MnemonicEntry):
|
|
|
+ wl_id = 'xmrseed'
|
|
|
+ modname = 'baseconv'
|
|
|
+ entry_modes = ('full','short')
|
|
|
+
|
|
|
+try:
|
|
|
+ MnemonicEntry.get_cfg_vars()
|
|
|
+except Exception as e:
|
|
|
+ m = "Error in cfg file option 'mnemonic_entry_modes':\n {}"
|
|
|
+ die(2,m.format(e.args[0]))
|