#!/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 . """ addrfile: Address and password file classes for the MMGen suite """ from .cfg import gc from .util import msg, die, capfirst from .protocol import init_proto from .obj import MMGenObject, TwComment, WalletPassword, MMGenPWIDString from .seed import SeedID, is_seed_id from .key import PrivKey from .addr import ViewKey, AddrListID, MMGenAddrType, MMGenPasswordType, is_addr_idx from .addrlist import AddrListData class AddrFile(MMGenObject): desc = 'addresses' ext = 'addrs' line_ctr = 0 header = """ # This file is editable. # Everything following a hash symbol '#' is a comment and ignored by {pnm}. """ text_label_header = """ # A text label of {n} screen cells or less may be added to the right of each # address, and it will be appended to the tracking wallet label upon import. # The label may contain any printable ASCII symbol. """ def __init__(self, parent): self.parent = parent self.cfg = parent.cfg self.infile = None self.fmt_data = None def encrypt(self): from .crypto import Crypto self.fmt_data = Crypto(self.cfg).mmgen_encrypt( data = self.fmt_data.encode(), desc = f'new {self.parent.desc} list') self.ext += f'.{Crypto.mmenc_ext}' @property def filename(self): return '{}{}.{}'.format( self.parent.id_str, ('.' + self.parent.proto.network) if self.parent.proto.testnet else '', self.ext) def write( self, fn = None, *, binary = False, desc = None, ask_overwrite = True, outdir = None): from .fileutil import write_data_to_file write_data_to_file( cfg = self.cfg, outfile = fn or self.filename, data = self.fmt_data or self.format(), desc = desc or self.desc, ask_tty = self.parent.has_keys and not self.cfg.quiet, binary = binary, ask_overwrite = ask_overwrite, outdir = outdir) def make_label(self): proto = self.parent.proto coin = proto.coin mmtype = self.parent.al_id.mmtype lbl_p2 = ':'.join( ([] if coin == 'BTC' or (coin == 'BCH' and not self.cfg.cashaddr) else [coin]) + ([] if mmtype == 'E' or (mmtype == 'L' and not proto.testnet) else [mmtype.name.upper()]) + ([proto.network.upper()] if proto.testnet else [])) return self.parent.al_id.sid + (' ' if lbl_p2 else '') + lbl_p2 def format(self, *, add_comments=False): p = self.parent if p.gen_passwds and p.pw_fmt in ('bip39', 'xmrseed'): desc_pfx = f'{p.pw_fmt.upper()} ' hdr2 = '' else: desc_pfx = '' hdr2 = self.text_label_header out = [ f'# {gc.proj_name} {desc_pfx}{p.desc} file\n#\n' + self.header.strip().format(pnm=gc.proj_name) + '\n' + hdr2.lstrip().format(n=TwComment.max_screen_width) + '#\n' ] if p.chksum: out.append(f'# {capfirst(p.desc)} data checksum for {p.id_str}: {p.chksum}') out.append('# Record this value to a secure location.\n') lbl = self.make_label() self.parent.dmsg_sc('lbl', lbl[9:]) out.append(f'{lbl} {{') fs = ' {:<%s} {:<34}{}' % len(str(p.data[-1].idx)) for e in p.data: c = ' ' + e.comment if add_comments and e.comment else '' match type(p).__name__: case 'KeyList': out.append(fs.format(e.idx, f'{p.al_id.mmtype.wif_label}: {e.sec.wif}', c)) case 'PasswordList': out.append(fs.format(e.idx, e.passwd, c)) case _: # First line with idx out.append(fs.format(e.idx, e.addr.views[e.addr.view_pref], c)) if p.has_keys: if self.cfg.b16: out.append(fs.format('', f'orig_hex: {e.sec.orig_bytes.hex()}', c)) if type(self) is not ViewKeyAddrFile: out.append(fs.format('', f'{p.al_id.mmtype.wif_label}: {e.sec.wif}', c)) for k in ('viewkey', 'wallet_passwd'): v = getattr(e, k) if v: out.append(fs.format('', f'{k}: {v}', c)) out.append('}') self.fmt_data = '\n'.join([l.rstrip() for l in out]) + '\n' return self.fmt_data def get_line(self, lines): ret = lines.pop(0).split(None, 2) self.line_ctr += 1 if ret[0] == 'orig_hex:': # hacky ret = lines.pop(0).split(None, 2) self.line_ctr += 1 return ret if len(ret) == 3 else ret + [''] def parse_file_body(self, lines): p = self.parent ret = AddrListData() le = p.entry_type iifs = "{!r}: invalid identifier [expected '{}:']" while lines: idx, addr, comment = self.get_line(lines) assert is_addr_idx(idx), f'invalid address index {idx!r}' p.check_format(addr) a = le(**{'proto': p.proto, 'idx': int(idx), p.main_attr: addr, 'comment': comment}) if p.has_keys: # order: wif, (orig_hex), viewkey, wallet_passwd if type(self) is not ViewKeyAddrFile: d = self.get_line(lines) assert d[0] == p.al_id.mmtype.wif_label+':', iifs.format(d[0], p.al_id.mmtype.wif_label) a.sec = PrivKey(proto=p.proto, wif=d[1]) for k, dtype, add_proto in ( ('viewkey', ViewKey, True), ('wallet_passwd', WalletPassword, False)): if k in p.al_id.mmtype.extra_attrs: d = self.get_line(lines) assert d[0] == k+':', iifs.format(d[0], k) setattr(a, k, dtype(*((p.proto, d[1]) if add_proto else (d[1],)))) ret.append(a) if type(self) is not ViewKeyAddrFile and p.has_keys and p.ka_validity_chk is not False: def verify_keys(): from .addrgen import KeyGenerator, AddrGenerator kg = KeyGenerator(self.cfg, p.proto, p.al_id.mmtype.pubkey_type) ag = AddrGenerator(self.cfg, p.proto, p.al_id.mmtype) llen = len(ret) qmsg_r = p.cfg._util.qmsg_r for n, e in enumerate(ret): qmsg_r(f'\rVerifying keys {n+1}/{llen}') assert e.addr == ag.to_addr(kg.gen_data(e.sec)), ( f'Key doesn’t match address!\n {e.sec.wif}\n {e.addr}') p.cfg._util.qmsg(' - done') if self.cfg.yes or p.ka_validity_chk: verify_keys() else: from .ui import keypress_confirm if keypress_confirm(p.cfg, 'Check key-to-address validity?'): verify_keys() return ret def parse_file(self, fn, *, buf=[], exit_on_error=True): def parse_addrfile_label(lbl): """ label examples: - Bitcoin legacy mainnet: no label - BCH legacy mainnet (no cashaddr): no label - BCH legacy mainnet (cashaddr): 'BCH' - Bitcoin legacy testnet: 'LEGACY:TESTNET' - Bitcoin Segwit: 'SEGWIT' - Bitcoin Segwit testnet: 'SEGWIT:TESTNET' - Bitcoin Bech32 regtest: 'BECH32:REGTEST' - Litecoin legacy mainnet: 'LTC' - Litecoin Bech32 mainnet: 'LTC:BECH32' - Litecoin legacy testnet: 'LTC:LEGACY:TESTNET' - Ethereum mainnet: 'ETH' - Ethereum Classic mainnet: 'ETC' - Ethereum regtest: 'ETH:REGTEST' """ lbl = lbl.lower() # remove the network component: if lbl.endswith(':testnet'): network = 'testnet' lbl = lbl[:-8] elif lbl.endswith(':regtest'): network = 'regtest' lbl = lbl[:-8] else: network = 'mainnet' from .proto.btc.params import mainnet if lbl in [MMGenAddrType(mainnet, key).name for key in mainnet.mmtypes]: coin, mmtype_key = ('BTC', lbl) elif ':' in lbl: # first component is coin, second is mmtype_key coin, mmtype_key = lbl.split(':') else: # only component is coin coin, mmtype_key = (lbl, None) proto = init_proto(p.cfg, coin=coin, network=network) if mmtype_key is None: mmtype_key = proto.mmtypes[0] return (proto, proto.addr_type(mmtype_key)) p = self.parent from .fileutil import get_lines_from_file lines = get_lines_from_file(p.cfg, fn, desc=f'{p.desc} data', trim_comments=True) try: assert len(lines) >= 3, f'Too few lines in address file ({len(lines)})' ls = lines[0].split() assert 1 < len(ls) < 5, f'Invalid first line for {p.gen_desc} file: {lines[0]!r}' assert ls[-1] == '{', f'{ls!r}: invalid first line' ls.pop() assert lines[-1] == '}', f'{lines[-1]!r}: invalid last line' sid = ls.pop(0) assert is_seed_id(sid), f'{sid!r}: invalid Seed ID' match len(ls): case 2 if type(p).__name__ == 'PasswordList': match ls.pop().split(':', 1): case [a, b]: p.set_pw_fmt(a) p.set_pw_len(b) case x: die(1, f'{x!r}: invalid password length specifier (must contain colon)') p.pw_id_str = MMGenPWIDString(ls.pop()) modname, funcname = p.pw_info[p.pw_fmt].chk_func.split('.') import importlib p.chk_func = getattr(importlib.import_module('mmgen.'+modname), funcname) proto = init_proto(p.cfg, 'btc') # FIXME: dummy protocol mmtype = MMGenPasswordType(proto, 'P') case 1: proto, mmtype = parse_addrfile_label(ls[0]) case 0: proto = init_proto(p.cfg, 'btc') mmtype = proto.addr_type('L') case _: raise ValueError(f'{lines[0]}: Invalid first line for {p.gen_desc} file {fn!r}') if type(p).__name__ != 'PasswordList': if proto.base_coin != p.proto.base_coin or proto.network != p.proto.network: # Having caller supply protocol and checking address file protocol against it here # allows us to catch all mismatches in one place. This behavior differs from that of # transaction files, which determine the protocol independently, requiring the caller # to check for protocol mismatches (e.g. mmgen.tx.completed.check_correct_chain()) raise ValueError( f'{p.desc} file is ' + f'{proto.base_coin} {proto.network} but protocol is ' + f'{p.proto.base_coin} {p.proto.network}') p.base_coin = proto.base_coin p.network = proto.network p.al_id = AddrListID(sid=SeedID(sid=sid), mmtype=mmtype) data = self.parse_file_body(lines[1:-1]) assert isinstance(data, list), 'Invalid file body data' except Exception as e: m_add = f', content line {self.line_ctr}' if self.line_ctr else '' m = f'Invalid data in {p.desc} list file ‘{fn}’{m_add} ({e!s})' if exit_on_error: die(3, m) else: msg(m) return False return data class KeyAddrFile(AddrFile): desc = 'secret keys' ext = 'akeys' class ViewKeyAddrFile(KeyAddrFile): desc = 'view keys' ext = 'vkeys' class KeyFile(KeyAddrFile): ext = 'keys' header = """ # This file is editable. # Everything following a hash symbol '#' is a comment and ignored by {pnm}. """ text_label_header = '' class PasswordFile(AddrFile): desc = 'passwords' ext = 'pws' header = """ # This file is editable. # Everything following a hash symbol '#' is a comment and ignored by {pnm}. """ text_label_header = """ # A text label of {n} screen cells or less may be added to the right of each # password. The label may contain any printable ASCII symbol. """ def get_line(self, lines): self.line_ctr += 1 p = self.parent if p.pw_fmt in ('bip39', 'xmrseed'): ret = lines.pop(0).split(None, p.pw_len + 1) match len(ret) - 1: case p.pw_len: return (ret[0], ' '.join(ret[1: p.pw_len + 1]), '') case x if x > p.pw_len: raise ValueError( f'extraneous text {ret[p.pw_len + 1]!r} found after password ' '[bare comments not allowed in mnemonic password files]') case x if x < p.pw_len: raise ValueError(f'invalid password length {x}') else: ret = lines.pop(0).split(None, 2) return ret if len(ret) == 3 else ret + [''] def make_label(self): p = self.parent return f'{p.al_id.sid} {p.pw_id_str} {p.pw_fmt_disp}:{p.pw_len}'