gentest.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2023 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. test/gentest.py: Cryptocoin key/address generation tests for the MMGen suite
  20. """
  21. import sys,os,time
  22. from include.tests_header import repo_root
  23. from test.overlay import overlay_setup
  24. sys.path.insert(0,overlay_setup(repo_root))
  25. # Import these _after_ local path's been added to sys.path
  26. import mmgen.opts as opts
  27. from mmgen.globalvars import g
  28. from mmgen.opts import opt
  29. from mmgen.color import green,red,purple
  30. from mmgen.util import msg,qmsg,qmsg_r,vmsg,capfirst,is_int,die
  31. results_file = 'gentest.out.json'
  32. rounds = 100
  33. opts_data = {
  34. 'text': {
  35. 'desc': 'Test key/address generation of the MMGen suite in various ways',
  36. 'usage':'[options] <spec> <rounds | dump file>',
  37. 'options': """
  38. -h, --help Print this help message
  39. --, --longhelp Print help message for long options (common options)
  40. -a, --all-coins Test all coins supported by specified external tool
  41. -k, --use-internal-keccak-module Force use of the internal keccak module
  42. -q, --quiet Produce quieter output
  43. -s, --save-results Save output of external tool in Compare test to
  44. {rf!r}
  45. -t, --type=t Specify address type (e.g. 'compressed','segwit',
  46. 'zcash_z','bech32')
  47. -v, --verbose Produce more verbose output
  48. """,
  49. 'notes': """
  50. TEST TYPES:
  51. Compare: {prog} A:B <rounds> (compare address generators A and B)
  52. Speed: {prog} A <rounds> (test speed of generator A)
  53. Dump: {prog} A <dump file> (compare generator A to wallet dump)
  54. where:
  55. A and B are keygen backend numbers ('1' being the default); or
  56. B is the name of an external tool (see below) or 'ext'.
  57. If B is 'ext', the external tool will be chosen automatically.
  58. For the Compare test, A may be 'all' to test all backends for the current
  59. coin/address type combination.
  60. EXAMPLES:
  61. Compare addresses generated by 'libsecp256k1' and 'python-ecdsa' backends,
  62. with 100 random rounds plus private-key edge cases:
  63. $ {prog} 1:2 100
  64. Compare Segwit addresses from default 'libsecp256k1' backend to 'pycoin'
  65. library for all supported coins, 100 rounds + edge cases:
  66. $ {prog} --all-coins --type=segwit 1:pycoin 100
  67. Compare addresses from 'python-ecdsa' backend to output of 'keyconv' tool
  68. for all supported coins, 100 rounds + edge cases:
  69. $ {prog} --all-coins --type=compressed 2:keyconv 100
  70. Compare bech32 addrs from 'libsecp256k1' backend to Bitcoin Core wallet
  71. dump:
  72. $ {prog} --type=bech32 1 bech32wallet.dump
  73. Compare addresses from Monero 'ed25519ll' backend to output of default
  74. external tool, 10 rounds + edge cases:
  75. $ {prog} --coin=xmr 3:ext 10
  76. Test the speed of default Monero 'nacl' backend, 10,000 rounds:
  77. $ test/gentest.py --coin=xmr 1 10000
  78. Same for Zcash:
  79. $ test/gentest.py --coin=zec --type=zcash_z 1 10000
  80. Test all configured Monero backends against the 'monero-python' library, 3 rounds
  81. + edge cases:
  82. $ test/gentest.py --coin=xmr all:monero-python 3
  83. Test 'nacl' and 'ed25519ll_djbec' backends against each other, 10,000 rounds
  84. + edge cases:
  85. $ test/gentest.py --coin=xmr 1:2 10000
  86. SUPPORTED EXTERNAL TOOLS:
  87. + ethkey (for ETH,ETC)
  88. https://github.com/openethereum/openethereum
  89. (build with 'cargo build -p ethkey-cli --release')
  90. + zcash-mini (for Zcash-Z addresses and view keys)
  91. https://github.com/FiloSottile/zcash-mini
  92. + monero-python (for Monero addresses and view keys)
  93. https://github.com/monero-ecosystem/monero-python
  94. + pycoin (for supported coins)
  95. https://github.com/richardkiss/pycoin
  96. + keyconv (for supported coins)
  97. https://github.com/exploitagency/vanitygen-plus
  98. ('keyconv' does not generate Segwit addresses)
  99. """
  100. },
  101. 'code': {
  102. 'options': lambda s: s.format(
  103. rf=results_file,
  104. ),
  105. 'notes': lambda s: s.format(
  106. prog='test/gentest.py',
  107. pnm=g.proj_name,
  108. snum=rounds )
  109. }
  110. }
  111. def get_cmd_output(cmd,input=None):
  112. return run(cmd,input=input,stdout=PIPE,stderr=DEVNULL).stdout.decode().splitlines()
  113. saved_results = {}
  114. class GenTool(object):
  115. def __init__(self,proto,addr_type):
  116. self.proto = proto
  117. self.addr_type = addr_type
  118. self.data = {}
  119. def __del__(self):
  120. if opt.save_results:
  121. key = f'{self.proto.coin}-{self.proto.network}-{self.addr_type.name}-{self.desc}'.lower()
  122. saved_results[key] = {k.hex():v._asdict() for k,v in self.data.items()}
  123. def run_tool(self,sec,cache_data):
  124. vcoin = 'BTC' if self.proto.coin == 'BCH' else self.proto.coin
  125. key = sec.orig_bytes
  126. if key in self.data:
  127. return self.data[key]
  128. else:
  129. ret = self.run(sec,vcoin)
  130. if cache_data:
  131. self.data[key] = sd( **{'reduced':sec.hex()}, **ret._asdict() )
  132. return ret
  133. class GenToolEthkey(GenTool):
  134. desc = 'ethkey'
  135. def __init__(self,*args,**kwargs):
  136. self.cmdname = get_ethkey()
  137. return super().__init__(*args,**kwargs)
  138. def run(self,sec,vcoin):
  139. o = get_cmd_output([self.cmdname,'info',sec.hex()])
  140. return gtr(
  141. o[0].split()[1],
  142. o[-1].split()[1],
  143. None )
  144. class GenToolKeyconv(GenTool):
  145. desc = 'keyconv'
  146. def run(self,sec,vcoin):
  147. o = get_cmd_output(['keyconv','-C',vcoin,sec.wif])
  148. return gtr(
  149. o[1].split()[1],
  150. o[0].split()[1],
  151. None )
  152. class GenToolZcash_mini(GenTool):
  153. desc = 'zcash-mini'
  154. def run(self,sec,vcoin):
  155. o = get_cmd_output(['zcash-mini','-key','-simple'],input=(sec.wif+'\n').encode())
  156. return gtr( o[1], o[0], o[-1] )
  157. class GenToolPycoin(GenTool):
  158. """
  159. pycoin/networks/all.py pycoin/networks/legacy_networks.py
  160. """
  161. desc = 'pycoin'
  162. def __init__(self,*args,**kwargs):
  163. super().__init__(*args,**kwargs)
  164. try:
  165. from pycoin.networks.registry import network_for_netcode
  166. except:
  167. raise ImportError('Unable to import pycoin.networks.registry. Is pycoin installed on your system?')
  168. self.nfnc = network_for_netcode
  169. def run(self,sec,vcoin):
  170. if self.proto.testnet:
  171. vcoin = cinfo.external_tests['testnet']['pycoin'][vcoin]
  172. network = self.nfnc(vcoin)
  173. key = network.keys.private(
  174. secret_exponent = int(sec.hex(),16),
  175. is_compressed = self.addr_type.name != 'legacy' )
  176. if key is None:
  177. die(1,f'can’t parse {sec.hex()}')
  178. if self.addr_type.name in ('segwit','bech32'):
  179. hash160_c = key.hash160(is_compressed=True)
  180. if self.addr_type.name == 'segwit':
  181. p2sh_script = network.contract.for_p2pkh_wit(hash160_c)
  182. addr = network.address.for_p2s(p2sh_script)
  183. else:
  184. addr = network.address.for_p2pkh_wit(hash160_c)
  185. else:
  186. addr = key.address()
  187. return gtr( key.wif(), addr, None )
  188. class GenToolMonero_python(GenTool):
  189. desc = 'monero-python'
  190. def __init__(self,*args,**kwargs):
  191. super().__init__(*args,**kwargs)
  192. try:
  193. from monero.seed import Seed
  194. except:
  195. raise ImportError('Unable to import monero-python. Is monero-python installed on your system?')
  196. self.Seed = Seed
  197. def run(self,sec,vcoin):
  198. seed = self.Seed( sec.orig_bytes.hex() )
  199. sk = seed.secret_spend_key()
  200. vk = seed.secret_view_key()
  201. addr = seed.public_address()
  202. return gtr( sk, addr, vk )
  203. def find_or_check_tool(proto,addr_type,toolname):
  204. ext_progs = list(cinfo.external_tests[proto.network])
  205. if toolname not in ext_progs + ['ext']:
  206. die(1,f'{toolname!r}: unsupported tool for network {proto.network}')
  207. if opt.all_coins and toolname == 'ext':
  208. die(1,"'--all-coins' must be combined with a specific external testing tool")
  209. else:
  210. tool = cinfo.get_test_support(
  211. proto.coin,
  212. addr_type.name,
  213. proto.network,
  214. verbose = not opt.quiet,
  215. toolname = toolname if toolname != 'ext' else None )
  216. if tool and toolname in ext_progs and toolname != tool:
  217. sys.exit(3)
  218. if tool == None:
  219. return None
  220. return tool
  221. def test_equal(desc,a_val,b_val,in_bytes,sec,wif,a_desc,b_desc):
  222. if a_val != b_val:
  223. fs = """
  224. {i:{w}}: {}
  225. {s:{w}}: {}
  226. {W:{w}}: {}
  227. {a:{w}}: {}
  228. {b:{w}}: {}
  229. """
  230. die(3,
  231. red('\nERROR: {} do not match!').format(desc)
  232. + fs.format(
  233. in_bytes.hex(), sec, wif, a_val, b_val,
  234. i='input', s='sec key', W='WIF key', a=a_desc, b=b_desc,
  235. w=max(len(e) for e in (a_desc,b_desc)) + 1
  236. ).rstrip())
  237. def do_ab_test(proto,cfg,addr_type,gen1,kg2,ag,tool,cache_data):
  238. def do_ab_inner(n,trounds,in_bytes):
  239. global last_t
  240. if opt.verbose or time.time() - last_t >= 0.1:
  241. qmsg_r(f'\rRound {i+1}/{trounds} ')
  242. last_t = time.time()
  243. sec = PrivKey(proto,in_bytes,compressed=addr_type.compressed,pubkey_type=addr_type.pubkey_type)
  244. data = kg1.gen_data(sec)
  245. addr1 = ag.to_addr(data)
  246. tinfo = ( in_bytes, sec, sec.wif, type(kg1).__name__, type(kg2).__name__ if kg2 else tool.desc )
  247. def do_msg():
  248. if opt.verbose:
  249. msg( fs.format( b=in_bytes.hex(), r=sec.hex(), k=sec.wif, v=vk2, a=addr1 ))
  250. if tool:
  251. def run_tool():
  252. o = tool.run_tool(sec,cache_data)
  253. test_equal( 'WIF keys', sec.wif, o.wif, *tinfo )
  254. test_equal( 'addresses', addr1, o.addr, *tinfo )
  255. if o.viewkey:
  256. test_equal( 'view keys', ag.to_viewkey(data), o.viewkey, *tinfo )
  257. return o.viewkey
  258. vk2 = run_tool()
  259. do_msg()
  260. else:
  261. test_equal( 'addresses', addr1, ag.to_addr(kg2.gen_data(sec)), *tinfo )
  262. vk2 = None
  263. do_msg()
  264. qmsg_r(f'\rRound {n+1}/{trounds} ')
  265. def get_randbytes():
  266. if tool and len(tool.data) > len(edgecase_sks):
  267. for privbytes in tuple(tool.data)[len(edgecase_sks):]:
  268. yield privbytes
  269. else:
  270. for i in range(cfg.rounds):
  271. yield getrand(32)
  272. kg1 = KeyGenerator( proto, addr_type.pubkey_type, gen1 )
  273. if type(kg1) == type(kg2):
  274. die(4,'Key generators are the same!')
  275. e = cinfo.get_entry(proto.coin,proto.network)
  276. qmsg(green("Comparing address generators '{A}' and '{B}' for {N} {c} ({n}), addrtype {a!r}".format(
  277. A = type(kg1).__name__.replace('_','-'),
  278. B = type(kg2).__name__.replace('_','-') if kg2 else tool.desc,
  279. N = proto.network,
  280. c = proto.coin,
  281. n = e.name if e else '---',
  282. a = addr_type.name )))
  283. global last_t
  284. last_t = time.time()
  285. fs = (
  286. '\ninput: {b}' +
  287. '\nreduced: {r}' +
  288. '\n{:9} {{k}}'.format(addr_type.wif_label+':') +
  289. ('\nviewkey: {v}' if 'viewkey' in addr_type.extra_attrs else '') +
  290. '\naddr: {a}\n' )
  291. ge = CoinProtocol.Secp256k1.secp256k1_ge
  292. # test some important private key edge cases:
  293. edgecase_sks = (
  294. bytes([0x00]*31 + [0x01]), # min
  295. bytes([0xff]*32), # max
  296. bytes([0x0f] + [0xff]*31), # produces same key as above for zcash-z
  297. int.to_bytes(ge + 1, 32, 'big'), # bitcoin will reduce
  298. int.to_bytes(ge - 1, 32, 'big'), # bitcoin will not reduce
  299. bytes([0x00]*31 + [0xff]), # monero will reduce
  300. bytes([0xff]*31 + [0x0f]), # monero will not reduce
  301. bytes.fromhex('deadbeef'*8),
  302. )
  303. qmsg(purple('edge cases:'))
  304. for i,privbytes in enumerate(edgecase_sks):
  305. do_ab_inner(i,len(edgecase_sks),privbytes)
  306. qmsg(green('\rOK ' if opt.verbose else 'OK'))
  307. qmsg(purple('random input:'))
  308. for i,privbytes in enumerate(get_randbytes()):
  309. do_ab_inner(i,cfg.rounds,privbytes)
  310. qmsg(green('\rOK ' if opt.verbose else 'OK'))
  311. def init_tool(proto,addr_type,toolname):
  312. return globals()['GenTool'+capfirst(toolname.replace('-','_'))](proto,addr_type)
  313. def ab_test(proto,cfg):
  314. addr_type = MMGenAddrType( proto=proto, id_str=opt.type or proto.dfl_mmtype )
  315. if cfg.gen2:
  316. assert cfg.gen1 != 'all', "'all' must be used only with external tool"
  317. kg2 = KeyGenerator( proto, addr_type.pubkey_type, cfg.gen2 )
  318. tool = None
  319. else:
  320. toolname = find_or_check_tool( proto, addr_type, cfg.tool )
  321. if toolname == None:
  322. ymsg(f'Warning: skipping tool {cfg.tool!r} for {proto.coin} {addr_type.name}')
  323. return
  324. tool = init_tool( proto, addr_type, toolname )
  325. kg2 = None
  326. ag = AddrGenerator( proto, addr_type )
  327. if cfg.all_backends: # check all backends against external tool
  328. for n in range(len(get_backends(addr_type.pubkey_type))):
  329. do_ab_test( proto, cfg, addr_type, gen1=n+1, kg2=kg2, ag=ag, tool=tool, cache_data=cfg.rounds < 1000 and not n )
  330. else: # check specific backend against external tool or another backend
  331. do_ab_test( proto, cfg, addr_type, gen1=cfg.gen1, kg2=kg2, ag=ag, tool=tool, cache_data=False )
  332. def speed_test(proto,kg,ag,rounds):
  333. qmsg(green('Testing speed of address generator {!r} for coin {}'.format(
  334. type(kg).__name__,
  335. proto.coin )))
  336. from struct import pack,unpack
  337. seed = getrand(28)
  338. qmsg('Incrementing key with each round')
  339. qmsg('Starting key: {}'.format( (seed + pack('I',0)).hex() ))
  340. import time
  341. start = last_t = time.time()
  342. for i in range(rounds):
  343. if time.time() - last_t >= 0.1:
  344. qmsg_r(f'\rRound {i+1}/{rounds} ')
  345. last_t = time.time()
  346. sec = PrivKey( proto, seed+pack('I', i), compressed=ag.compressed, pubkey_type=ag.pubkey_type )
  347. addr = ag.to_addr(kg.gen_data(sec))
  348. vmsg(f'\nkey: {sec.wif}\naddr: {addr}\n')
  349. qmsg(
  350. f'\rRound {i+1}/{rounds} ' +
  351. f'\n{rounds} addresses generated' +
  352. ('' if g.test_suite_deterministic else f' in {time.time()-start:.2f} seconds')
  353. )
  354. def dump_test(proto,kg,ag,filename):
  355. with open(filename) as fp:
  356. dump = [[*(e.split()[0] for e in line.split('addr='))] for line in fp.readlines() if 'addr=' in line]
  357. if not dump:
  358. die(1,f'File {filename!r} appears not to be a wallet dump')
  359. qmsg(green(
  360. "A: generator pair '{}:{}'\nB: wallet dump {!r}".format(
  361. type(kg).__name__,
  362. type(ag).__name__,
  363. filename)))
  364. for count,(b_wif,b_addr) in enumerate(dump,1):
  365. qmsg_r(f'\rKey {count}/{len(dump)} ')
  366. try:
  367. b_sec = PrivKey(proto,wif=b_wif)
  368. except:
  369. die(2,f'\nInvalid {proto.network} WIF address in dump file: {b_wif}')
  370. a_addr = ag.to_addr(kg.gen_data(b_sec))
  371. vmsg(f'\nwif: {b_wif}\naddr: {b_addr}\n')
  372. tinfo = (b_sec,b_sec.hex(),b_wif,type(kg).__name__,filename)
  373. test_equal('addresses',a_addr,b_addr,*tinfo)
  374. qmsg(green(('\n','')[bool(opt.verbose)] + 'OK'))
  375. def get_protos(proto,addr_type,toolname):
  376. init_genonly_altcoins(testnet=proto.testnet)
  377. for coin in cinfo.external_tests[proto.network][toolname]:
  378. if coin.lower() not in CoinProtocol.coins:
  379. continue
  380. ret = init_proto(coin,testnet=proto.testnet)
  381. if addr_type not in ret.mmtypes:
  382. continue
  383. yield ret
  384. def parse_args():
  385. if len(cmd_args) != 2:
  386. opts.usage()
  387. arg1,arg2 = cmd_args
  388. gen1,gen2,rounds = (0,0,0)
  389. tool,all_backends,dumpfile = (None,None,None)
  390. if is_int(arg1) and is_int(arg2):
  391. test = 'speed'
  392. gen1 = arg1
  393. rounds = arg2
  394. elif is_int(arg1) and os.access(arg2,os.R_OK):
  395. test = 'dump'
  396. gen1 = arg1
  397. dumpfile = arg2
  398. else:
  399. test = 'ab'
  400. rounds = arg2
  401. if not is_int(arg2):
  402. die(1,'Second argument must be dump filename or integer rounds specification')
  403. try:
  404. a,b = arg1.split(':')
  405. except:
  406. die(1,'First argument must be a generator backend number or two colon-separated arguments')
  407. if is_int(a):
  408. gen1 = a
  409. else:
  410. if a == 'all':
  411. all_backends = True
  412. else:
  413. die(1,"First part of first argument must be a generator backend number or 'all'")
  414. if is_int(b):
  415. if opt.all_coins:
  416. die(1,'--all-coins must be used with external tool only')
  417. gen2 = b
  418. else:
  419. tool = b
  420. proto = init_proto_from_opts()
  421. ext_progs = list(cinfo.external_tests[proto.network]) + ['ext']
  422. if b not in ext_progs:
  423. die(1,f'Second part of first argument must be a generator backend number or one of {ext_progs}')
  424. return namedtuple('parsed_args',['test','gen1','gen2','rounds','tool','all_backends','dumpfile'])(
  425. test,
  426. int(gen1) or None,
  427. int(gen2) or None,
  428. int(rounds) or None,
  429. tool,
  430. all_backends,
  431. dumpfile )
  432. def main():
  433. cfg = parse_args()
  434. proto = init_proto_from_opts()
  435. addr_type = MMGenAddrType( proto=proto, id_str=opt.type or proto.dfl_mmtype )
  436. if cfg.test == 'ab':
  437. protos = get_protos(proto,addr_type,cfg.tool) if opt.all_coins else [proto]
  438. for proto in protos:
  439. ab_test( proto, cfg )
  440. else:
  441. kg = KeyGenerator( proto, addr_type.pubkey_type, cfg.gen1 )
  442. ag = AddrGenerator( proto, addr_type )
  443. if cfg.test == 'speed':
  444. speed_test( proto, kg, ag, cfg.rounds )
  445. elif cfg.test == 'dump':
  446. dump_test( proto, kg, ag, cfg.dumpfile )
  447. if saved_results:
  448. import json
  449. with open(results_file,'w') as fp:
  450. fp.write(json.dumps( saved_results, indent=4 ))
  451. from subprocess import run,PIPE,DEVNULL
  452. from collections import namedtuple
  453. from mmgen.protocol import init_proto,init_proto_from_opts,CoinProtocol
  454. from mmgen.altcoin import init_genonly_altcoins,CoinInfo as cinfo
  455. from mmgen.key import PrivKey
  456. from mmgen.addr import MMGenAddrType
  457. from mmgen.addrgen import KeyGenerator,AddrGenerator
  458. from mmgen.keygen import get_backends
  459. from test.include.common import getrand,get_ethkey
  460. gtr = namedtuple('gen_tool_result',['wif','addr','viewkey'])
  461. sd = namedtuple('saved_data_item',['reduced','wif','addr','viewkey'])
  462. sys.argv = [sys.argv[0]] + ['--skip-cfg-file'] + sys.argv[1:]
  463. cmd_args = opts.init(opts_data)
  464. main()