passwdlist.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 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. passwdlist: Password list class for the MMGen suite
  20. """
  21. from collections import namedtuple
  22. from .util import ymsg, is_int, die
  23. from .obj import ImmutableAttr, ListItemAttr, MMGenPWIDString, TwComment
  24. from .key import PrivKey
  25. from .addr import MMGenPasswordType, AddrIdx, AddrListID
  26. from .addrlist import (
  27. AddrListChksum,
  28. AddrListIDStr,
  29. AddrListEntryBase,
  30. AddrList,
  31. )
  32. class PasswordListEntry(AddrListEntryBase):
  33. passwd = ListItemAttr(str, typeconv=False) # TODO: create Password type
  34. idx = ImmutableAttr(AddrIdx)
  35. comment = ListItemAttr(TwComment, reassign_ok=True)
  36. sec = ListItemAttr(PrivKey, include_proto=True)
  37. class PasswordList(AddrList):
  38. entry_type = PasswordListEntry
  39. main_attr = 'passwd'
  40. desc = 'password'
  41. gen_desc = 'password'
  42. gen_desc_pl = 's'
  43. gen_addrs = False
  44. gen_keys = False
  45. gen_passwds = True
  46. pw_len = None
  47. dfl_pw_fmt = 'b58'
  48. pwinfo = namedtuple('passwd_info', ['min_len', 'max_len', 'dfl_len', 'valid_lens', 'desc', 'chk_func'])
  49. pw_info = {
  50. # 32**25 < 2**128 < 32**26
  51. 'b32': pwinfo(10, 42 , 24, None, 'base32 password', 'baseconv.is_b32_str'),
  52. # 58**21 < 2**128 < 58**22
  53. 'b58': pwinfo(8, 36 , 20, None, 'base58 password', 'baseconv.is_b58_str'),
  54. 'bip39': pwinfo(12, 24 , 24, [12, 18, 24], 'BIP39 mnemonic', 'bip39.is_bip39_mnemonic'),
  55. 'xmrseed': pwinfo(25, 25, 25, [25], 'Monero new-style mnemonic', 'xmrseed.is_xmrseed'),
  56. 'hex': pwinfo(32, 64 , 64, [32, 48, 64], 'hexadecimal password', 'util.is_hex_str')}
  57. chksum_rec_f = lambda foo, e: (str(e.idx), e.passwd)
  58. feature_warn_fs = 'WARNING: {!r} is a potentially dangerous feature. Use at your own risk!'
  59. hex2bip39 = False
  60. def __init__(
  61. self,
  62. cfg,
  63. proto,
  64. *,
  65. infile = None,
  66. seed = None,
  67. pw_idxs = None,
  68. pw_id_str = None,
  69. pw_len = None,
  70. pw_fmt = None,
  71. chk_params_only = False,
  72. skip_chksum_msg = False):
  73. self.cfg = cfg
  74. self.proto = proto # proto is ignored
  75. if not cfg.debug_addrlist:
  76. self.dmsg_sc = self.noop
  77. if infile:
  78. self.infile = infile
  79. # sets self.pw_id_str, self.pw_fmt, self.pw_len, self.chk_func:
  80. self.data = self.file.parse_file(infile)
  81. else:
  82. if not chk_params_only:
  83. for k in (seed, pw_idxs):
  84. assert k
  85. self.pw_id_str = MMGenPWIDString(pw_id_str)
  86. self.set_pw_fmt(pw_fmt)
  87. self.set_pw_len(pw_len)
  88. if chk_params_only:
  89. return
  90. if self.hex2bip39:
  91. ymsg(self.feature_warn_fs.format(pw_fmt))
  92. self.set_pw_len_vs_seed_len(seed) # sets self.bip39, self.xmrseed, self.xmrproto self.baseconv
  93. self.al_id = AddrListID(sid=seed.sid, mmtype=MMGenPasswordType(self.proto, 'P'))
  94. self.data = self.generate(seed, pw_idxs)
  95. self.num_addrs = len(self.data)
  96. self.fmt_data = ''
  97. self.chksum = AddrListChksum(self)
  98. fs = f'{self.al_id.sid}-{self.pw_id_str}-{self.pw_fmt_disp}-{self.pw_len}[{{}}]'
  99. self.id_str = AddrListIDStr(self, fmt_str=fs)
  100. if not skip_chksum_msg:
  101. self.do_chksum_msg(record=not infile)
  102. def set_pw_fmt(self, pw_fmt):
  103. if pw_fmt == 'hex2bip39':
  104. self.hex2bip39 = True
  105. self.pw_fmt = 'bip39'
  106. self.pw_fmt_disp = 'hex2bip39'
  107. else:
  108. self.pw_fmt = pw_fmt
  109. self.pw_fmt_disp = pw_fmt
  110. if self.pw_fmt not in self.pw_info:
  111. die('InvalidPasswdFormat',
  112. f'{self.pw_fmt!r}: invalid password format. Valid formats: {", ".join(self.pw_info)}')
  113. def chk_pw_len(self):
  114. assert self.pw_len, 'pw_len must be set'
  115. fs = '{l}: invalid user-requested length for {b} ({c}{m})'
  116. d = self.pw_info[self.pw_fmt]
  117. match self.pw_len:
  118. case pw_len if d.valid_lens and pw_len not in d.valid_lens:
  119. die(2, fs.format(l=pw_len, b=d.desc, c='not one of ', m=d.valid_lens))
  120. case pw_len if pw_len > d.max_len:
  121. die(2, fs.format(l=pw_len, b=d.desc, c='>', m=d.max_len))
  122. case pw_len if pw_len < d.min_len:
  123. die(2, fs.format(l=pw_len, b=d.desc, c='<', m=d.min_len))
  124. def set_pw_len(self, pw_len):
  125. d = self.pw_info[self.pw_fmt]
  126. if pw_len is None:
  127. self.pw_len = d.dfl_len
  128. return
  129. if not is_int(pw_len):
  130. die(2, f'{pw_len!r}: invalid user-requested password length (not an integer)')
  131. self.pw_len = int(pw_len)
  132. self.chk_pw_len()
  133. def set_pw_len_vs_seed_len(self, seed):
  134. match self.pw_fmt:
  135. case 'hex':
  136. pw_bytes = self.pw_len // 2
  137. good_pw_len = seed.byte_len * 2
  138. case 'bip39':
  139. from .bip39 import bip39
  140. self.bip39 = bip39()
  141. pw_bytes = bip39.nwords2seedlen(self.pw_len, in_bytes=True)
  142. good_pw_len = bip39.seedlen2nwords(seed.byte_len, in_bytes=True)
  143. case 'xmrseed':
  144. from .xmrseed import xmrseed
  145. from .protocol import init_proto
  146. self.xmrseed = xmrseed()
  147. self.xmrproto = init_proto(self.cfg, 'xmr')
  148. pw_bytes = xmrseed().seedlen_map_rev[self.pw_len]
  149. try:
  150. good_pw_len = xmrseed().seedlen_map[seed.byte_len]
  151. except:
  152. die(1, f'{seed.byte_len*8}: unsupported seed length for Monero new-style mnemonic')
  153. case 'b32' | 'b58' as pf:
  154. pw_int = (32 if pf == 'b32' else 58) ** self.pw_len
  155. pw_bytes = pw_int.bit_length() // 8
  156. from .baseconv import baseconv
  157. self.baseconv = baseconv(self.pw_fmt)
  158. good_pw_len = len(baseconv(pf).frombytes(b'\xff'*seed.byte_len))
  159. case pf:
  160. raise NotImplementedError(f'{pf!r}: unknown password format')
  161. match pw_bytes:
  162. case x if x > seed.byte_len:
  163. die(1,
  164. f'Cannot generate passwords with more entropy than underlying seed! ({len(seed.data)*8} bits)\n' +
  165. (f'Re-run the command with --passwd-len={good_pw_len}' if self.pw_fmt in ('bip39', 'hex') else
  166. 'Re-run the command, specifying a password length of {} or less')
  167. )
  168. case x if x < seed.byte_len and self.pw_fmt in ('bip39', 'hex'):
  169. from .ui import keypress_confirm
  170. keypress_confirm(
  171. self.cfg,
  172. f'WARNING: requested {self.pw_info[self.pw_fmt].desc} length has less entropy ' +
  173. 'than underlying seed!\nIs this what you want?',
  174. default_yes = True,
  175. do_exit = True)
  176. def gen_passwd(self, secbytes):
  177. match self.pw_fmt:
  178. case 'hex':
  179. return secbytes.hex()[:self.pw_len] # take most significant part
  180. case 'bip39':
  181. pw_len_bytes = self.bip39.nwords2seedlen(self.pw_len, in_bytes=True)
  182. return ' '.join(self.bip39.fromhex(secbytes[:pw_len_bytes].hex())) # take m.s.p.
  183. case 'xmrseed':
  184. pw_len_bytes = self.xmrseed.seedlen_map_rev[self.pw_len]
  185. bytes_preproc = self.xmrproto.preprocess_key(
  186. secbytes[:pw_len_bytes], # take most significant part
  187. None)
  188. return ' '.join(self.xmrseed.frombytes(bytes_preproc))
  189. case x if x in self.pw_info:
  190. return self.baseconv.frombytes(
  191. secbytes,
  192. pad = self.pw_len,
  193. tostr = True)[-self.pw_len:] # take least significant part
  194. case x:
  195. die(2, f'{x}: unrecognized password format')
  196. def check_format(self, pw):
  197. if not self.chk_func(pw):
  198. raise ValueError(f'Password is not valid {self.pw_info[self.pw_fmt].desc} data')
  199. pwlen = len(pw.split()) if self.pw_fmt in ('bip39', 'xmrseed') else len(pw)
  200. if pwlen != self.pw_len:
  201. raise ValueError(f'Password has incorrect length ({pwlen} != {self.pw_len})')
  202. return True
  203. def scramble_seed(self, seed):
  204. # Changing either pw_fmt or pw_len will cause a different, unrelated
  205. # set of passwords to be generated: this is what we want.
  206. # NB: In original implementation, pw_id_str was 'baseN', not 'bN'
  207. scramble_key = f'{self.pw_fmt}:{self.pw_len}:{self.pw_id_str}'
  208. if self.hex2bip39:
  209. pwlen = self.bip39.nwords2seedlen(self.pw_len, in_hex=True)
  210. scramble_key = f'hex:{pwlen}:{self.pw_id_str}'
  211. self.dmsg_sc('str', scramble_key)
  212. from .crypto import Crypto
  213. return Crypto(self.cfg).scramble_seed(seed, scramble_key.encode())