#!/usr/bin/env python3 # # MMGen Wallet, a terminal-based cryptocurrency wallet # Copyright (C)2013-2025 The MMGen Project # # 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 . """ mn_entry.py - Mnemonic user entry methods for the MMGen suite """ import sys, time from .util import msg, msg_r, fmt, fmt_list, capfirst, die, ascii_lowercase from .term import get_char, get_char_raw from .color import cyan _return_chars = '\n\r ' _erase_chars = '\b\x7f' class MnEntryMode: """ 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_bytes=1) 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, _ = self.get_char(s) if ch in _return_chars: if s: break elif ch in ascii_lowercase: 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() 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, _ = self.get_char(s) if ch in _return_chars: if s: break elif ch in ascii_lowercase: 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() 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, _ = self.get_char(s) if ch in _return_chars: if s: break elif ch in ascii_lowercase: 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 ascii_lowercase: s += ch ret = mne.idx(s, 'minimal', lo_idx=lo, hi_idx=hi) if not isinstance(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) class MnemonicEntry: 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 #{}: ') usr_dfl_entry_mode = None _lw = None _sw = None _usl = None def __init__(self, cfg): self.cfg = cfg self.set_dfl_entry_mode() @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 getattr(sys.modules[__name__], '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, indent=' '*14).lstrip().format(usl=self.uniq_ss_len), )) prompt = f'Type a number, or hit ENTER for the default ({capfirst(self.dfl_entry_mode)}): ' erase = '\r' + ' ' * (len(prompt)+19) + '\r' while True: uret = get_char(prompt).strip() if uret == '': msg_r(erase) return self.get_cls_by_entry_mode(self.dfl_entry_mode)(self) elif uret in [str(i) for i in range(1, len(em_objs)+1)]: msg_r(erase) return em_objs[int(uret)-1] else: msg_r(f'\b {uret!r}: invalid choice ') time.sleep(self.cfg.err_disp_timeout) msg_r(erase) def get_mnemonic_from_user(self, mn_len, *, validate=True): mll = list(self.bconv.seedlen_map_rev) assert mn_len in mll, f'{mn_len}: invalid mnemonic length (must be one of {mll})' if self.usr_dfl_entry_mode: em = self.get_cls_by_entry_mode(self.usr_dfl_entry_mode)(self) i_add = ' (user-configured)' else: em = self.choose_entry_mode() i_add = '' msg('\r' + f'Using entry mode {cyan(em.name.upper())}{i_add}') self.em = em if not self.usr_dfl_entry_mode: msg('\n' + ( fmt(self.prompt_info['intro']) + '\n' + fmt(self.prompt_info['pad_info'].rstrip() + em.pad_max_info + em.prompt_info, indent=' ') ).format( ml = mn_len, ssl = em.ss_len, pad_max = em.pad_max, sw = self.shortest_word, )) clear_line = '\n' if self.cfg.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.bconv.tohex(words) 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: raise ValueError(f'wordlist {wl!r} not recognized (valid choices: {fmt_list(list(d))})') return d[wl] def set_dfl_entry_mode(self): """ In addition to setting the default entry mode for the current wordlist, checks validity of all user-configured entry modes """ for k, v in self.cfg.mnemonic_entry_modes.items(): cls = self.get_cls_by_wordlist(k) if v not in cls.entry_modes: errmsg = f""" Error in cfg file option 'mnemonic_entry_modes': Entry mode {v!r} not recognized for wordlist {k!r}: Valid choices: {fmt_list(cls.entry_modes)} """ die(2, '\n' + fmt(errmsg, indent=' ')) if cls == type(self): self.usr_dfl_entry_mode = v class MnemonicEntryMMGen(MnemonicEntry): wl_id = 'mmgen' modname = 'baseconv' entry_modes = ('full', 'minimal', 'fixed') dfl_entry_mode = 'minimal' has_chksum = False class MnemonicEntryBIP39(MnemonicEntry): wl_id = 'bip39' modname = 'bip39' entry_modes = ('full', 'short', 'fixed') dfl_entry_mode = 'fixed' has_chksum = True class MnemonicEntryMonero(MnemonicEntry): wl_id = 'xmrseed' modname = 'xmrseed' entry_modes = ('full', 'short') dfl_entry_mode = 'short' has_chksum = True def mn_entry(cfg, wl_id, *, entry_mode=None): if wl_id == 'words': wl_id = 'mmgen' me = MnemonicEntry.get_cls_by_wordlist(wl_id)(cfg) import importlib me.bconv = getattr(importlib.import_module(f'mmgen.{me.modname}'), me.modname)(wl_id) me.wl = me.bconv.digits if entry_mode: me.em = getattr(sys.modules[__name__], 'MnEntryMode' + capfirst(entry_mode))(me) return me