addrfile.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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. addrfile: Address and password file classes for the MMGen suite
  20. """
  21. from .cfg import gc
  22. from .util import msg, die, capfirst
  23. from .protocol import init_proto
  24. from .obj import MMGenObject, TwComment, WalletPassword, MMGenPWIDString
  25. from .seed import SeedID, is_seed_id
  26. from .key import PrivKey
  27. from .addr import ViewKey, AddrListID, MMGenAddrType, MMGenPasswordType, is_addr_idx
  28. from .addrlist import KeyList, AddrListData
  29. class AddrFile(MMGenObject):
  30. desc = 'addresses'
  31. ext = 'addrs'
  32. line_ctr = 0
  33. header = """
  34. # This file is editable.
  35. # Everything following a hash symbol '#' is a comment and ignored by {pnm}.
  36. """
  37. text_label_header = """
  38. # A text label of {n} screen cells or less may be added to the right of each
  39. # address, and it will be appended to the tracking wallet label upon import.
  40. # The label may contain any printable ASCII symbol.
  41. """
  42. def __init__(self, parent):
  43. self.parent = parent
  44. self.cfg = parent.cfg
  45. self.infile = None
  46. self.fmt_data = None
  47. def encrypt(self):
  48. from .crypto import Crypto
  49. self.fmt_data = Crypto(self.cfg).mmgen_encrypt(
  50. data = self.fmt_data.encode(),
  51. desc = f'new {self.parent.desc} list')
  52. self.ext += f'.{Crypto.mmenc_ext}'
  53. @property
  54. def filename(self):
  55. return '{}{}.{}'.format(
  56. self.parent.id_str,
  57. ('.' + self.parent.proto.network) if self.parent.proto.testnet else '',
  58. self.ext)
  59. def write(
  60. self,
  61. fn = None,
  62. binary = False,
  63. desc = None,
  64. ask_overwrite = True,
  65. outdir = None):
  66. from .fileutil import write_data_to_file
  67. write_data_to_file(
  68. cfg = self.cfg,
  69. outfile = fn or self.filename,
  70. data = self.fmt_data or self.format(),
  71. desc = desc or self.desc,
  72. ask_tty = self.parent.has_keys and not self.cfg.quiet,
  73. binary = binary,
  74. ask_overwrite = ask_overwrite,
  75. outdir = outdir)
  76. def make_label(self):
  77. proto = self.parent.proto
  78. coin = proto.coin
  79. mmtype = self.parent.al_id.mmtype
  80. lbl_p2 = ':'.join(
  81. ([] if coin == 'BTC' or (coin == 'BCH' and not self.cfg.cashaddr) else [coin])
  82. + ([] if mmtype == 'E' or (mmtype == 'L' and not proto.testnet) else [mmtype.name.upper()])
  83. + ([proto.network.upper()] if proto.testnet else [])
  84. )
  85. return self.parent.al_id.sid + (' ' if lbl_p2 else '') + lbl_p2
  86. def format(self, add_comments=False):
  87. p = self.parent
  88. if p.gen_passwds and p.pw_fmt in ('bip39', 'xmrseed'):
  89. desc_pfx = f'{p.pw_fmt.upper()} '
  90. hdr2 = ''
  91. else:
  92. desc_pfx = ''
  93. hdr2 = self.text_label_header
  94. out = [
  95. f'# {gc.proj_name} {desc_pfx}{p.desc} file\n#\n'
  96. + self.header.strip().format(pnm=gc.proj_name)
  97. + '\n'
  98. + hdr2.lstrip().format(n=TwComment.max_screen_width)
  99. + '#\n'
  100. ]
  101. if p.chksum:
  102. out.append(f'# {capfirst(p.desc)} data checksum for {p.id_str}: {p.chksum}')
  103. out.append('# Record this value to a secure location.\n')
  104. lbl = self.make_label()
  105. self.parent.dmsg_sc('lbl', lbl[9:])
  106. out.append(f'{lbl} {{')
  107. fs = ' {:<%s} {:<34}{}' % len(str(p.data[-1].idx))
  108. for e in p.data:
  109. c = ' ' + e.comment if add_comments and e.comment else ''
  110. if type(p) is KeyList:
  111. out.append(fs.format(e.idx, f'{p.al_id.mmtype.wif_label}: {e.sec.wif}', c))
  112. elif type(p).__name__ == 'PasswordList':
  113. out.append(fs.format(e.idx, e.passwd, c))
  114. else: # First line with idx
  115. out.append(fs.format(e.idx, e.addr.views[e.addr.view_pref], c))
  116. if p.has_keys:
  117. if self.cfg.b16:
  118. out.append(fs.format('', f'orig_hex: {e.sec.orig_bytes.hex()}', c))
  119. if type(self) is not ViewKeyAddrFile:
  120. out.append(fs.format('', f'{p.al_id.mmtype.wif_label}: {e.sec.wif}', c))
  121. for k in ('viewkey', 'wallet_passwd'):
  122. v = getattr(e, k)
  123. if v:
  124. out.append(fs.format('', f'{k}: {v}', c))
  125. out.append('}')
  126. self.fmt_data = '\n'.join([l.rstrip() for l in out]) + '\n'
  127. return self.fmt_data
  128. def get_line(self, lines):
  129. ret = lines.pop(0).split(None, 2)
  130. self.line_ctr += 1
  131. if ret[0] == 'orig_hex:': # hacky
  132. ret = lines.pop(0).split(None, 2)
  133. self.line_ctr += 1
  134. return ret if len(ret) == 3 else ret + ['']
  135. def parse_file_body(self, lines):
  136. p = self.parent
  137. ret = AddrListData()
  138. le = p.entry_type
  139. iifs = "{!r}: invalid identifier [expected '{}:']"
  140. while lines:
  141. idx, addr, comment = self.get_line(lines)
  142. assert is_addr_idx(idx), f'invalid address index {idx!r}'
  143. p.check_format(addr)
  144. a = le(**{'proto': p.proto, 'idx':int(idx), p.main_attr:addr, 'comment':comment})
  145. if p.has_keys: # order: wif, (orig_hex), viewkey, wallet_passwd
  146. if type(self) is not ViewKeyAddrFile:
  147. d = self.get_line(lines)
  148. assert d[0] == p.al_id.mmtype.wif_label+':', iifs.format(d[0], p.al_id.mmtype.wif_label)
  149. a.sec = PrivKey(proto=p.proto, wif=d[1])
  150. for k, dtype, add_proto in (
  151. ('viewkey', ViewKey, True),
  152. ('wallet_passwd', WalletPassword, False)):
  153. if k in p.al_id.mmtype.extra_attrs:
  154. d = self.get_line(lines)
  155. assert d[0] == k+':', iifs.format(d[0], k)
  156. setattr(a, k, dtype(*((p.proto, d[1]) if add_proto else (d[1],))))
  157. ret.append(a)
  158. if type(self) is not ViewKeyAddrFile and p.has_keys and p.ka_validity_chk is not False:
  159. def verify_keys():
  160. from .addrgen import KeyGenerator, AddrGenerator
  161. kg = KeyGenerator(self.cfg, p.proto, p.al_id.mmtype.pubkey_type)
  162. ag = AddrGenerator(self.cfg, p.proto, p.al_id.mmtype)
  163. llen = len(ret)
  164. qmsg_r = p.cfg._util.qmsg_r
  165. for n, e in enumerate(ret):
  166. qmsg_r(f'\rVerifying keys {n+1}/{llen}')
  167. assert e.addr == ag.to_addr(kg.gen_data(e.sec)), (
  168. f'Key doesn’t match address!\n {e.sec.wif}\n {e.addr}')
  169. p.cfg._util.qmsg(' - done')
  170. if self.cfg.yes or p.ka_validity_chk:
  171. verify_keys()
  172. else:
  173. from .ui import keypress_confirm
  174. if keypress_confirm(p.cfg, 'Check key-to-address validity?'):
  175. verify_keys()
  176. return ret
  177. def parse_file(self, fn, buf=[], exit_on_error=True):
  178. def parse_addrfile_label(lbl):
  179. """
  180. label examples:
  181. - Bitcoin legacy mainnet: no label
  182. - BCH legacy mainnet (no cashaddr): no label
  183. - BCH legacy mainnet (cashaddr): 'BCH'
  184. - Bitcoin legacy testnet: 'LEGACY:TESTNET'
  185. - Bitcoin Segwit: 'SEGWIT'
  186. - Bitcoin Segwit testnet: 'SEGWIT:TESTNET'
  187. - Bitcoin Bech32 regtest: 'BECH32:REGTEST'
  188. - Litecoin legacy mainnet: 'LTC'
  189. - Litecoin Bech32 mainnet: 'LTC:BECH32'
  190. - Litecoin legacy testnet: 'LTC:LEGACY:TESTNET'
  191. - Ethereum mainnet: 'ETH'
  192. - Ethereum Classic mainnet: 'ETC'
  193. - Ethereum regtest: 'ETH:REGTEST'
  194. """
  195. lbl = lbl.lower()
  196. # remove the network component:
  197. if lbl.endswith(':testnet'):
  198. network = 'testnet'
  199. lbl = lbl[:-8]
  200. elif lbl.endswith(':regtest'):
  201. network = 'regtest'
  202. lbl = lbl[:-8]
  203. else:
  204. network = 'mainnet'
  205. from .proto.btc.params import mainnet
  206. if lbl in [MMGenAddrType(mainnet, key).name for key in mainnet.mmtypes]:
  207. coin, mmtype_key = ('BTC', lbl)
  208. elif ':' in lbl: # first component is coin, second is mmtype_key
  209. coin, mmtype_key = lbl.split(':')
  210. else: # only component is coin
  211. coin, mmtype_key = (lbl, None)
  212. proto = init_proto(p.cfg, coin=coin, network=network)
  213. if mmtype_key is None:
  214. mmtype_key = proto.mmtypes[0]
  215. return (proto, proto.addr_type(mmtype_key))
  216. p = self.parent
  217. from .fileutil import get_lines_from_file
  218. lines = get_lines_from_file(p.cfg, fn, p.desc+' data', trim_comments=True)
  219. try:
  220. assert len(lines) >= 3, f'Too few lines in address file ({len(lines)})'
  221. ls = lines[0].split()
  222. assert 1 < len(ls) < 5, f'Invalid first line for {p.gen_desc} file: {lines[0]!r}'
  223. assert ls[-1] == '{', f'{ls!r}: invalid first line'
  224. ls.pop()
  225. assert lines[-1] == '}', f'{lines[-1]!r}: invalid last line'
  226. sid = ls.pop(0)
  227. assert is_seed_id(sid), f'{sid!r}: invalid Seed ID'
  228. if type(p).__name__ == 'PasswordList' and len(ls) == 2:
  229. ss = ls.pop().split(':')
  230. assert len(ss) == 2, f'{ss!r}: invalid password length specifier (must contain colon)'
  231. p.set_pw_fmt(ss[0])
  232. p.set_pw_len(ss[1])
  233. p.pw_id_str = MMGenPWIDString(ls.pop())
  234. modname, funcname = p.pw_info[p.pw_fmt].chk_func.split('.')
  235. import importlib
  236. p.chk_func = getattr(importlib.import_module('mmgen.'+modname), funcname)
  237. proto = init_proto(p.cfg, 'btc') # FIXME: dummy protocol
  238. mmtype = MMGenPasswordType(proto, 'P')
  239. elif len(ls) == 1:
  240. proto, mmtype = parse_addrfile_label(ls[0])
  241. elif len(ls) == 0:
  242. proto = init_proto(p.cfg, 'btc')
  243. mmtype = proto.addr_type('L')
  244. else:
  245. raise ValueError(f'{lines[0]}: Invalid first line for {p.gen_desc} file {fn!r}')
  246. if type(p).__name__ != 'PasswordList':
  247. if proto.base_coin != p.proto.base_coin or proto.network != p.proto.network:
  248. # Having caller supply protocol and checking address file protocol against it here
  249. # allows us to catch all mismatches in one place. This behavior differs from that of
  250. # transaction files, which determine the protocol independently, requiring the caller
  251. # to check for protocol mismatches (e.g. mmgen.tx.completed.check_correct_chain())
  252. raise ValueError(
  253. f'{p.desc} file is '
  254. + f'{proto.base_coin} {proto.network} but protocol is '
  255. + f'{p.proto.base_coin} {p.proto.network}')
  256. p.base_coin = proto.base_coin
  257. p.network = proto.network
  258. p.al_id = AddrListID(sid=SeedID(sid=sid), mmtype=mmtype)
  259. data = self.parse_file_body(lines[1:-1])
  260. assert isinstance(data, list), 'Invalid file body data'
  261. except Exception as e:
  262. m = 'Invalid data in {} list file {!r}{} ({!s})'.format(
  263. p.desc,
  264. self.infile,
  265. (f', content line {self.line_ctr}' if self.line_ctr else ''),
  266. e)
  267. if exit_on_error:
  268. die(3, m)
  269. else:
  270. msg(m)
  271. return False
  272. return data
  273. class KeyAddrFile(AddrFile):
  274. desc = 'secret keys'
  275. ext = 'akeys'
  276. class ViewKeyAddrFile(KeyAddrFile):
  277. desc = 'view keys'
  278. ext = 'vkeys'
  279. class KeyFile(KeyAddrFile):
  280. ext = 'keys'
  281. header = """
  282. # This file is editable.
  283. # Everything following a hash symbol '#' is a comment and ignored by {pnm}.
  284. """
  285. text_label_header = ''
  286. class PasswordFile(AddrFile):
  287. desc = 'passwords'
  288. ext = 'pws'
  289. header = """
  290. # This file is editable.
  291. # Everything following a hash symbol '#' is a comment and ignored by {pnm}.
  292. """
  293. text_label_header = """
  294. # A text label of {n} screen cells or less may be added to the right of each
  295. # password. The label may contain any printable ASCII symbol.
  296. """
  297. def get_line(self, lines):
  298. self.line_ctr += 1
  299. p = self.parent
  300. if p.pw_fmt in ('bip39', 'xmrseed'):
  301. ret = lines.pop(0).split(None, p.pw_len+1)
  302. if len(ret) > p.pw_len+1:
  303. m1 = f'extraneous text {ret[p.pw_len+1]!r} found after password'
  304. m2 = '[bare comments not allowed in BIP39 password files]'
  305. m = m1+' '+m2
  306. elif len(ret) < p.pw_len+1:
  307. m = f'invalid password length {len(ret)-1}'
  308. else:
  309. return (ret[0], ' '.join(ret[1:p.pw_len+1]), '')
  310. raise ValueError(m)
  311. else:
  312. ret = lines.pop(0).split(None, 2)
  313. return ret if len(ret) == 3 else ret + ['']
  314. def make_label(self):
  315. p = self.parent
  316. return f'{p.al_id.sid} {p.pw_id_str} {p.pw_fmt_disp}:{p.pw_len}'