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,gc
  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=gc.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()