main_autosign.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2019 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. mmgen-autosign: Auto-sign MMGen transactions
  20. """
  21. import sys,os,time,signal,shutil
  22. from subprocess import run,PIPE,DEVNULL
  23. from stat import *
  24. mountpoint = '/mnt/tx'
  25. tx_dir = '/mnt/tx/tx'
  26. part_label = 'MMGEN_TX'
  27. wallet_dir = '/dev/shm/autosign'
  28. key_fn = 'autosign.key'
  29. from mmgen.common import *
  30. prog_name = os.path.basename(sys.argv[0])
  31. opts_data = {
  32. 'text': {
  33. 'desc': 'Auto-sign MMGen transactions',
  34. 'usage':'[opts] [command]',
  35. 'options': """
  36. -h, --help Print this help message
  37. --, --longhelp Print help message for long options (common options)
  38. -c, --coins=c Coins to sign for (comma-separated list)
  39. -I, --no-insert-check Don't check for device insertion
  40. -l, --led Use status LED to signal standby, busy and error
  41. -m, --mountpoint=m Specify an alternate mountpoint (default: '{mp}')
  42. -s, --stealth-led Stealth LED mode - signal busy and error only, and only
  43. after successful authorization.
  44. -S, --full-summary Print a full summary of each signed transaction after
  45. each autosign run. The default list of non-MMGen outputs
  46. will not be printed.
  47. -q, --quiet Produce quieter output
  48. -v, --verbose Produce more verbose output
  49. """.format(mp=mountpoint),
  50. 'notes': """
  51. COMMANDS
  52. gen_key - generate the wallet encryption key and copy it to '{td}'
  53. setup - generate the wallet encryption key and wallet
  54. wait - start in loop mode: wait-mount-sign-unmount-wait
  55. USAGE NOTES
  56. If invoked with no command, the program mounts a removable device containing
  57. MMGen transactions, signs any unsigned transactions, unmounts the removable
  58. device and exits.
  59. If invoked with 'wait', the program waits in a loop, mounting, signing and
  60. unmounting every time the removable device is inserted.
  61. On supported platforms (currently Orange Pi and Raspberry Pi boards), the
  62. status LED indicates whether the program is busy or in standby mode, i.e.
  63. ready for device insertion or removal.
  64. The removable device must have a partition labeled MMGEN_TX and a user-
  65. writable directory '/tx', where unsigned MMGen transactions are placed.
  66. On the signing machine the mount point '{mp}' must exist and /etc/fstab
  67. must contain the following entry:
  68. LABEL='MMGEN_TX' /mnt/tx auto noauto,user 0 0
  69. Transactions are signed with a wallet on the signing machine (in the directory
  70. '{wd}') encrypted with a 64-character hexadecimal password on the
  71. removable device.
  72. The password and wallet can be created in one operation by invoking the
  73. command with 'setup' with the removable device inserted. The user will be
  74. prompted for a seed mnemonic.
  75. Alternatively, the password and wallet can be created separately by first
  76. invoking the command with 'gen_key' and then creating and encrypting the
  77. wallet using the -P (--passwd-file) option:
  78. $ mmgen-walletconv -r0 -q -iwords -d{wd} -p1 -P{td}/{kf} -Llabel
  79. Note that the hash preset must be '1'. Multiple wallets are permissible.
  80. For good security, it's advisable to re-generate a new wallet and key for
  81. each signing session.
  82. This command is currently available only on Linux-based platforms.
  83. """.format(pnm=prog_name,wd=wallet_dir,td=tx_dir,kf=key_fn,mp=mountpoint)
  84. }
  85. }
  86. cmd_args = opts.init(opts_data,add_opts=['mmgen_keys_from_file','in_fmt'])
  87. exit_if_mswin('autosigning')
  88. import mmgen.tx
  89. import mmgen.altcoins.eth.tx
  90. from mmgen.txsign import txsign
  91. from mmgen.protocol import CoinProtocol,init_coin
  92. if g.test_suite:
  93. from mmgen.daemon import CoinDaemon
  94. if opt.stealth_led: opt.led = True
  95. if opt.mountpoint: mountpoint = opt.mountpoint # TODO: make global
  96. opt.outdir = tx_dir = os.path.join(mountpoint,'tx')
  97. def check_daemons_running():
  98. if opt.coin:
  99. die(1,'--coin option not supported with this command. Use --coins instead')
  100. if opt.coins:
  101. coins = opt.coins.upper().split(',')
  102. else:
  103. ymsg('Warning: no coins specified, so defaulting to BTC only')
  104. coins = ['BTC']
  105. for coin in coins:
  106. g.proto = CoinProtocol(coin,g.testnet)
  107. if g.proto.sign_mode != 'daemon':
  108. continue
  109. if g.test_suite:
  110. g.proto.daemon_data_dir = 'test/daemons/' + coin.lower()
  111. g.rpc_port = CoinDaemon(get_network_id(coin,g.testnet),test_suite=True).rpc_port
  112. vmsg('Checking {} daemon'.format(coin))
  113. try:
  114. rpc_init(reinit=True)
  115. g.rpch.getblockcount()
  116. except SystemExit as e:
  117. if e.code != 0:
  118. fs = '{} daemon not running or not listening on port {}'
  119. ydie(1,fs.format(coin,g.proto.rpc_port))
  120. def get_wallet_files():
  121. m = "Cannot open wallet directory '{}'. Did you run 'mmgen-autosign setup'?"
  122. try: dlist = os.listdir(wallet_dir)
  123. except: die(1,m.format(wallet_dir))
  124. wfs = [x for x in dlist if x[-6:] == '.mmdat']
  125. if not wfs:
  126. die(1,'No wallet files present!')
  127. return [os.path.join(wallet_dir,w) for w in wfs]
  128. def do_mount():
  129. if not os.path.ismount(mountpoint):
  130. if run(['mount',mountpoint],stderr=DEVNULL,stdout=DEVNULL).returncode == 0:
  131. msg('Mounting '+mountpoint)
  132. try:
  133. ds = os.stat(tx_dir)
  134. m1 = "'{}' is not a directory!"
  135. m2 = "'{}' is not read/write for this user!"
  136. assert S_ISDIR(ds.st_mode),m1.format(tx_dir)
  137. assert ds.st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR,m2.format(tx_dir)
  138. except:
  139. die(1,'{} missing, or not read/writable by user!'.format(tx_dir))
  140. def do_umount():
  141. if os.path.ismount(mountpoint):
  142. run(['sync'],check=True)
  143. msg('Unmounting '+mountpoint)
  144. run(['umount',mountpoint],check=True)
  145. def sign_tx_file(txfile,signed_txs):
  146. try:
  147. init_coin('BTC',testnet=False)
  148. tmp_tx = mmgen.tx.MMGenTX(txfile,metadata_only=True)
  149. init_coin(tmp_tx.coin)
  150. if tmp_tx.chain != 'mainnet':
  151. if tmp_tx.chain == 'testnet' or (
  152. hasattr(g.proto,'chain_name') and tmp_tx.chain != g.proto.chain_name):
  153. init_coin(tmp_tx.coin,testnet=True)
  154. if hasattr(g.proto,'chain_name'):
  155. m = 'Chains do not match! tx file: {}, proto: {}'
  156. assert tmp_tx.chain == g.proto.chain_name,m.format(tmp_tx.chain,g.proto.chain_name)
  157. g.chain = tmp_tx.chain
  158. g.token = tmp_tx.dcoin
  159. g.dcoin = tmp_tx.dcoin or g.coin
  160. tx = mmgen.tx.MMGenTX(txfile,offline=True)
  161. if g.proto.sign_mode == 'daemon':
  162. if g.test_suite:
  163. g.proto.daemon_data_dir = 'test/daemons/' + g.coin.lower()
  164. g.rpc_port = CoinDaemon(get_network_id(g.coin,g.testnet),test_suite=True).rpc_port
  165. rpc_init(reinit=True)
  166. if txsign(tx,wfs,None,None):
  167. tx.write_to_file(ask_write=False)
  168. signed_txs.append(tx)
  169. return True
  170. else:
  171. return False
  172. except Exception as e:
  173. msg('An error occurred: {}'.format(e.args[0]))
  174. if g.debug or g.traceback:
  175. print_stack_trace('AUTOSIGN {}'.format(txfile))
  176. return False
  177. except:
  178. return False
  179. def sign():
  180. dirlist = os.listdir(tx_dir)
  181. raw = [f for f in dirlist if f[-6:] == '.rawtx']
  182. signed = [f[:-6] for f in dirlist if f[-6:] == '.sigtx']
  183. unsigned = [os.path.join(tx_dir,f) for f in raw if f[:-6] not in signed]
  184. if unsigned:
  185. signed_txs,fails = [],[]
  186. for txfile in unsigned:
  187. ret = sign_tx_file(txfile,signed_txs)
  188. if not ret:
  189. fails.append(txfile)
  190. qmsg('')
  191. time.sleep(0.3)
  192. msg('{} transaction{} signed'.format(len(signed_txs),suf(signed_txs)))
  193. if fails:
  194. rmsg('{} transaction{} failed to sign'.format(len(fails),suf(fails)))
  195. if signed_txs:
  196. print_summary(signed_txs)
  197. if fails:
  198. rmsg('{}Failed transactions:'.format('' if opt.full_summary else '\n'))
  199. rmsg(' ' + '\n '.join(sorted(fails)) + '\n')
  200. return False if fails else True
  201. else:
  202. msg('No unsigned transactions')
  203. time.sleep(1)
  204. return True
  205. def decrypt_wallets():
  206. opt.hash_preset = '1'
  207. opt.set_by_user = ['hash_preset']
  208. opt.passwd_file = os.path.join(tx_dir,key_fn)
  209. # opt.passwd_file = '/tmp/key'
  210. from mmgen.seed import SeedSource
  211. msg("Unlocking wallet{} with key from '{}'".format(suf(wfs),opt.passwd_file))
  212. fails = 0
  213. for wf in wfs:
  214. try:
  215. SeedSource(wf)
  216. except SystemExit as e:
  217. if e.code != 0:
  218. fails += 1
  219. return False if fails else True
  220. def print_summary(signed_txs):
  221. if opt.full_summary:
  222. bmsg('\nAutosign summary:\n')
  223. for tx in signed_txs:
  224. init_coin(tx.coin,tx.chain == 'testnet')
  225. msg_r(tx.format_view(terse=True))
  226. return
  227. body = []
  228. for tx in signed_txs:
  229. non_mmgen = [o for o in tx.outputs if not o.mmid]
  230. if non_mmgen:
  231. body.append((tx,non_mmgen))
  232. if body:
  233. bmsg('\nAutosign summary:')
  234. fs = '{} {} {}'
  235. t_wid,a_wid = 6,44
  236. msg(fs.format('TX ID ','Non-MMGen outputs'+' '*(a_wid-17),'Amount'))
  237. msg(fs.format('-'*t_wid, '-'*a_wid, '-'*7))
  238. for tx,non_mmgen in body:
  239. for nm in non_mmgen:
  240. msg(fs.format(
  241. tx.txid.fmt(width=t_wid,color=True) if nm is non_mmgen[0] else ' '*t_wid,
  242. nm.addr.fmt(width=a_wid,color=True),
  243. nm.amt.hl() + ' ' + yellow(tx.coin)))
  244. else:
  245. msg('No non-MMGen outputs')
  246. def do_sign():
  247. if not opt.stealth_led: set_led('busy')
  248. do_mount()
  249. key_ok = decrypt_wallets()
  250. if key_ok:
  251. if opt.stealth_led: set_led('busy')
  252. ret = sign()
  253. do_umount()
  254. set_led(('standby','off','error')[(not ret)*2 or bool(opt.stealth_led)])
  255. return ret
  256. else:
  257. msg('Password is incorrect!')
  258. do_umount()
  259. if not opt.stealth_led: set_led('error')
  260. return False
  261. def wipe_existing_key():
  262. fn = os.path.join(tx_dir,key_fn)
  263. try: os.stat(fn)
  264. except: pass
  265. else:
  266. msg('\nWiping existing key {}'.format(fn))
  267. run(['wipe','-cf',fn],check=True)
  268. def create_key():
  269. kdata = os.urandom(32).hex()
  270. fn = os.path.join(tx_dir,key_fn)
  271. desc = 'key file {}'.format(fn)
  272. msg('Creating ' + desc)
  273. try:
  274. open(fn,'w').write(kdata+'\n')
  275. os.chmod(fn,0o400)
  276. msg('Wrote ' + desc)
  277. except:
  278. die(2,'Unable to write ' + desc)
  279. def gen_key(no_unmount=False):
  280. create_wallet_dir()
  281. if not get_insert_status():
  282. die(1,'Removable device not present!')
  283. do_mount()
  284. wipe_existing_key()
  285. create_key()
  286. if not no_unmount:
  287. do_umount()
  288. def remove_wallet_dir():
  289. msg("Deleting '{}'".format(wallet_dir))
  290. try: shutil.rmtree(wallet_dir)
  291. except: pass
  292. def create_wallet_dir():
  293. try: os.mkdir(wallet_dir)
  294. except: pass
  295. try: os.stat(wallet_dir)
  296. except: die(2,"Unable to create wallet directory '{}'".format(wallet_dir))
  297. def setup():
  298. remove_wallet_dir()
  299. gen_key(no_unmount=True)
  300. from mmgen.seed import SeedSource
  301. opt.hidden_incog_input_params = None
  302. opt.quiet = True
  303. opt.in_fmt = 'words'
  304. ss_in = SeedSource()
  305. opt.out_fmt = 'mmdat'
  306. opt.usr_randchars = 0
  307. opt.hash_preset = '1'
  308. opt.set_by_user = ['hash_preset']
  309. opt.passwd_file = os.path.join(tx_dir,key_fn)
  310. from mmgen.obj import MMGenWalletLabel
  311. opt.label = MMGenWalletLabel('Autosign Wallet')
  312. ss_out = SeedSource(ss=ss_in)
  313. ss_out.write_to_file(desc='autosign wallet',outdir=wallet_dir)
  314. def ev_sleep(secs):
  315. ev.wait(secs)
  316. return ev.isSet()
  317. def do_led(on,off):
  318. if not on:
  319. open(status_ctl,'w').write('0\n')
  320. while True:
  321. if ev_sleep(3600): return
  322. while True:
  323. for s_time,val in ((on,255),(off,0)):
  324. open(status_ctl,'w').write('{}\n'.format(val))
  325. if ev_sleep(s_time): return
  326. def set_led(cmd):
  327. if not opt.led: return
  328. vmsg("Setting LED state to '{}'".format(cmd))
  329. timings = {
  330. 'off': ( 0, 0 ),
  331. 'standby': ( 2.2, 0.2 ),
  332. 'busy': ( 0.06, 0.06 ),
  333. 'error': ( 0.5, 0.5 )}[cmd]
  334. global led_thread
  335. if led_thread:
  336. ev.set(); led_thread.join(); ev.clear()
  337. led_thread = threading.Thread(target=do_led,name='LED loop',args=timings)
  338. led_thread.start()
  339. def get_insert_status():
  340. if opt.no_insert_check: return True
  341. try: os.stat(os.path.join('/dev/disk/by-label',part_label))
  342. except: return False
  343. else: return True
  344. def do_loop():
  345. n,prev_status = 0,False
  346. if not opt.stealth_led:
  347. set_led('standby')
  348. while True:
  349. status = get_insert_status()
  350. if status and not prev_status:
  351. msg('Device insertion detected')
  352. do_sign()
  353. prev_status = status
  354. if not n % 10:
  355. msg_r('\r{}\rWaiting'.format(' '*17))
  356. sys.stderr.flush()
  357. time.sleep(1)
  358. msg_r('.')
  359. n += 1
  360. def check_access(fn,desc='status LED control',init_val=None):
  361. try:
  362. b = open(fn).read().strip()
  363. open(fn,'w').write('{}\n'.format(init_val or b))
  364. return True
  365. except:
  366. m1 = "You do not have access to the {} file\n".format(desc)
  367. m2 = "To allow access, run 'sudo chmod 0666 {}'".format(fn)
  368. msg(m1+m2)
  369. return False
  370. def check_wipe_present():
  371. try:
  372. run(['wipe','-v'],stdout=DEVNULL,stderr=DEVNULL,check=True)
  373. except:
  374. die(2,"The 'wipe' utility must be installed before running this program")
  375. def init_led():
  376. sc = {
  377. 'opi': '/sys/class/leds/orangepi:red:status/brightness',
  378. 'rpi': '/sys/class/leds/led0/brightness'
  379. }
  380. tc = {
  381. 'rpi': '/sys/class/leds/led0/trigger', # mmc,none
  382. }
  383. for k in ('opi','rpi'):
  384. try: os.stat(sc[k])
  385. except: pass
  386. else:
  387. board = k
  388. break
  389. else:
  390. die(2,'Control files not found! LED option not supported')
  391. status_ctl = sc[board]
  392. trigger_ctl = tc[board] if board in tc else None
  393. if not check_access(status_ctl) or (
  394. trigger_ctl and not check_access(trigger_ctl,desc='LED trigger',init_val='none')
  395. ):
  396. sys.exit(1)
  397. if trigger_ctl:
  398. open(trigger_ctl,'w').write('none\n')
  399. return status_ctl,trigger_ctl
  400. # main()
  401. if len(cmd_args) not in (0,1):
  402. opts.usage()
  403. if len(cmd_args) == 1:
  404. if cmd_args[0] in ('gen_key','setup'):
  405. globals()[cmd_args[0]]()
  406. sys.exit(0)
  407. elif cmd_args[0] != 'wait':
  408. die(1,"'{}': unrecognized command".format(cmd_args[0]))
  409. check_wipe_present()
  410. wfs = get_wallet_files()
  411. check_daemons_running()
  412. def at_exit(exit_val,nl=False):
  413. if nl: msg('')
  414. msg('Cleaning up...')
  415. if opt.led:
  416. set_led('off')
  417. ev.set()
  418. led_thread.join()
  419. if trigger_ctl:
  420. open(trigger_ctl,'w').write('mmc0\n')
  421. sys.exit(exit_val)
  422. def handler(a,b): at_exit(1,nl=True)
  423. signal.signal(signal.SIGTERM,handler)
  424. signal.signal(signal.SIGINT,handler)
  425. if opt.led:
  426. import threading
  427. status_ctl,trigger_ctl = init_led()
  428. ev = threading.Event()
  429. led_thread = None
  430. if len(cmd_args) == 0:
  431. ret = do_sign()
  432. at_exit(int(not ret))
  433. elif cmd_args[0] == 'wait':
  434. do_loop()