main_autosign.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2021 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. mn_fmts = {
  30. 'mmgen': 'words',
  31. 'bip39': 'bip39',
  32. }
  33. mn_fmt_dfl = 'mmgen'
  34. from .common import *
  35. opts.UserOpts._set_ok += ('outdir','passwd_file')
  36. prog_name = os.path.basename(sys.argv[0])
  37. opts_data = {
  38. 'sets': [('stealth_led', True, 'led', True)],
  39. 'text': {
  40. 'desc': 'Auto-sign MMGen transactions',
  41. 'usage':'[opts] [command]',
  42. 'options': """
  43. -h, --help Print this help message
  44. --, --longhelp Print help message for long options (common options)
  45. -c, --coins=c Coins to sign for (comma-separated list)
  46. -I, --no-insert-check Don’t check for device insertion
  47. -l, --led Use status LED to signal standby, busy and error
  48. -m, --mountpoint=M Specify an alternate mountpoint 'M' (default: '{mp}')
  49. -M, --mnemonic-fmt=F During setup, prompt for mnemonic seed phrase of format
  50. 'F' (choices: {mc}; default: '{md}')
  51. -n, --no-summary Don’t print a transaction summary
  52. -s, --stealth-led Stealth LED mode - signal busy and error only, and only
  53. after successful authorization.
  54. -S, --full-summary Print a full summary of each signed transaction after
  55. each autosign run. The default list of non-MMGen outputs
  56. will not be printed.
  57. -q, --quiet Produce quieter output
  58. -v, --verbose Produce more verbose output
  59. """.format(
  60. md = mn_fmt_dfl,
  61. mc = fmt_list(mn_fmts,fmt='no_spc'),
  62. mp = mountpoint
  63. ),
  64. 'notes': """
  65. COMMANDS
  66. gen_key - generate the wallet encryption key and copy it to '{td}'
  67. setup - generate the wallet encryption key and wallet
  68. wait - start in loop mode: wait-mount-sign-unmount-wait
  69. USAGE NOTES
  70. If invoked with no command, the program mounts a removable device containing
  71. MMGen transactions, signs any unsigned transactions, unmounts the removable
  72. device and exits.
  73. If invoked with 'wait', the program waits in a loop, mounting, signing and
  74. unmounting every time the removable device is inserted.
  75. On supported platforms (currently Orange Pi and Raspberry Pi boards), the
  76. status LED indicates whether the program is busy or in standby mode, i.e.
  77. ready for device insertion or removal.
  78. The removable device must have a partition labeled MMGEN_TX and a user-
  79. writable directory '/tx', where unsigned MMGen transactions are placed.
  80. On the signing machine the mount point '{mp}' must exist and /etc/fstab
  81. must contain the following entry:
  82. LABEL='MMGEN_TX' /mnt/tx auto noauto,user 0 0
  83. Transactions are signed with a wallet on the signing machine (in the directory
  84. '{wd}') encrypted with a 64-character hexadecimal password on the
  85. removable device.
  86. The password and wallet can be created in one operation by invoking the
  87. command with 'setup' with the removable device inserted. The user will be
  88. prompted for a seed mnemonic.
  89. Alternatively, the password and wallet can be created separately by first
  90. invoking the command with 'gen_key' and then creating and encrypting the
  91. wallet using the -P (--passwd-file) option:
  92. $ mmgen-walletconv -r0 -q -iwords -d{wd} -p1 -P{td}/{kf} -Llabel
  93. Note that the hash preset must be '1'. Multiple wallets are permissible.
  94. For good security, it's advisable to re-generate a new wallet and key for
  95. each signing session.
  96. This command is currently available only on Linux-based platforms.
  97. """.format(pnm=prog_name,wd=wallet_dir,td=tx_dir,kf=key_fn,mp=mountpoint)
  98. }
  99. }
  100. cmd_args = opts.init(
  101. opts_data,
  102. add_opts = ['mmgen_keys_from_file','hidden_incog_input_params'],
  103. init_opts = {
  104. 'quiet': True,
  105. 'out_fmt': 'wallet',
  106. 'usr_randchars': 0,
  107. 'hash_preset': '1',
  108. 'label': 'Autosign Wallet',
  109. })
  110. exit_if_mswin('autosigning')
  111. if opt.mnemonic_fmt:
  112. if opt.mnemonic_fmt not in mn_fmts:
  113. die(1,'{!r}: invalid mnemonic format (must be one of: {})'.format(
  114. opt.mnemonic_fmt,
  115. fmt_list(mn_fmts,fmt='no_spc') ))
  116. import mmgen.tx
  117. from .wallet import Wallet
  118. from .txsign import txsign
  119. from .protocol import init_proto
  120. from .rpc import rpc_init
  121. if g.test_suite:
  122. from .daemon import CoinDaemon
  123. if opt.mountpoint:
  124. mountpoint = opt.mountpoint
  125. opt.outdir = tx_dir = os.path.join(mountpoint,'tx')
  126. opt.passwd_file = os.path.join(tx_dir,key_fn)
  127. async def check_daemons_running():
  128. if opt.coin:
  129. die(1,'--coin option not supported with this command. Use --coins instead')
  130. if opt.coins:
  131. coins = opt.coins.upper().split(',')
  132. else:
  133. ymsg('Warning: no coins specified, defaulting to BTC')
  134. coins = ['BTC']
  135. for coin in coins:
  136. proto = init_proto(coin,testnet=g.testnet)
  137. if proto.sign_mode == 'daemon':
  138. vmsg(f'Checking {coin} daemon')
  139. try:
  140. await rpc_init(proto)
  141. except SocketError as e:
  142. ydie(1,f'{coin} daemon not running or not listening on port {proto.rpc_port}')
  143. def get_wallet_files():
  144. try:
  145. dlist = os.listdir(wallet_dir)
  146. except:
  147. die(1,f"Cannot open wallet directory {wallet_dir!r}. Did you run 'mmgen-autosign setup'?")
  148. fns = [x for x in dlist if x.endswith('.mmdat')]
  149. if fns:
  150. return [os.path.join(wallet_dir,w) for w in fns]
  151. else:
  152. die(1,'No wallet files present!')
  153. def do_mount():
  154. if not os.path.ismount(mountpoint):
  155. if run(['mount',mountpoint],stderr=DEVNULL,stdout=DEVNULL).returncode == 0:
  156. msg(f'Mounting {mountpoint}')
  157. try:
  158. ds = os.stat(tx_dir)
  159. assert S_ISDIR(ds.st_mode), f'{tx_dir!r} is not a directory!'
  160. assert ds.st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR, f'{tx_dir!r} is not read/write for this user!'
  161. except:
  162. die(1,f'{tx_dir!r} missing or not read/writable by user!')
  163. def do_umount():
  164. if os.path.ismount(mountpoint):
  165. run(['sync'],check=True)
  166. msg(f'Unmounting {mountpoint}')
  167. run(['umount',mountpoint],check=True)
  168. async def sign_tx_file(txfile):
  169. from .tx import MMGenTX
  170. try:
  171. tx1 = MMGenTX.Unsigned(filename=txfile)
  172. if tx1.proto.sign_mode == 'daemon':
  173. tx1.rpc = await rpc_init(tx1.proto)
  174. tx2 = await txsign(tx1,wfs,None,None)
  175. if tx2:
  176. tx2.write_to_file(ask_write=False)
  177. return tx2
  178. else:
  179. return False
  180. except Exception as e:
  181. ymsg(f'An error occurred with transaction {txfile!r}:\n {e!s}')
  182. return False
  183. except:
  184. ymsg(f'An error occurred with transaction {txfile!r}')
  185. return False
  186. async def sign():
  187. dirlist = os.listdir(tx_dir)
  188. raw,signed = [set(f[:-6] for f in dirlist if f.endswith(ext)) for ext in ('.rawtx','.sigtx')]
  189. unsigned = [os.path.join(tx_dir,f+'.rawtx') for f in raw - signed]
  190. if unsigned:
  191. signed_txs,fails = [],[]
  192. for txfile in unsigned:
  193. ret = await sign_tx_file(txfile)
  194. if ret:
  195. signed_txs.append(ret)
  196. else:
  197. fails.append(txfile)
  198. qmsg('')
  199. time.sleep(0.3)
  200. msg(f'{len(signed_txs)} transaction{suf(signed_txs)} signed')
  201. if fails:
  202. rmsg(f'{len(fails)} transaction{suf(fails)} failed to sign')
  203. if signed_txs and not opt.no_summary:
  204. print_summary(signed_txs)
  205. if fails:
  206. rmsg('\nFailed transactions:')
  207. rmsg(' ' + '\n '.join(sorted(fails)) + '\n')
  208. return False if fails else True
  209. else:
  210. msg('No unsigned transactions')
  211. time.sleep(1)
  212. return True
  213. def decrypt_wallets():
  214. msg(f'Unlocking wallet{suf(wfs)} with key from {opt.passwd_file!r}')
  215. fails = 0
  216. for wf in wfs:
  217. try:
  218. Wallet(wf,ignore_in_fmt=True)
  219. except SystemExit as e:
  220. if e.code != 0:
  221. fails += 1
  222. return False if fails else True
  223. def print_summary(signed_txs):
  224. if opt.full_summary:
  225. bmsg('\nAutosign summary:\n')
  226. def gen():
  227. for tx in signed_txs:
  228. yield tx.format_view(terse=True)
  229. msg_r(''.join(gen()))
  230. return
  231. def gen():
  232. for tx in signed_txs:
  233. non_mmgen = [o for o in tx.outputs if not o.mmid]
  234. if non_mmgen:
  235. yield (tx,non_mmgen)
  236. body = list(gen())
  237. if body:
  238. bmsg('\nAutosign summary:')
  239. fs = '{} {} {}'
  240. t_wid,a_wid = 6,44
  241. def gen():
  242. yield fs.format('TX ID ','Non-MMGen outputs'+' '*(a_wid-17),'Amount')
  243. yield fs.format('-'*t_wid, '-'*a_wid, '-'*7)
  244. for tx,non_mmgen in body:
  245. for nm in non_mmgen:
  246. yield fs.format(
  247. tx.txid.fmt(width=t_wid,color=True) if nm is non_mmgen[0] else ' '*t_wid,
  248. nm.addr.fmt(width=a_wid,color=True),
  249. nm.amt.hl() + ' ' + yellow(tx.coin))
  250. msg('\n'.join(gen()))
  251. else:
  252. msg('No non-MMGen outputs')
  253. async def do_sign():
  254. if not opt.stealth_led:
  255. led.set('busy')
  256. do_mount()
  257. key_ok = decrypt_wallets()
  258. if key_ok:
  259. if opt.stealth_led:
  260. led.set('busy')
  261. ret = await sign()
  262. do_umount()
  263. led.set(('standby','off','error')[(not ret)*2 or bool(opt.stealth_led)])
  264. return ret
  265. else:
  266. msg('Password is incorrect!')
  267. do_umount()
  268. if not opt.stealth_led:
  269. led.set('error')
  270. return False
  271. def wipe_existing_key():
  272. fn = os.path.join(tx_dir,key_fn)
  273. try: os.stat(fn)
  274. except: pass
  275. else:
  276. msg(f'\nWiping existing key {fn!r}')
  277. run(['wipe','-cf',fn],check=True)
  278. def create_key():
  279. kdata = os.urandom(32).hex()
  280. fn = os.path.join(tx_dir,key_fn)
  281. desc = f'key file {fn!r}'
  282. msg('Creating ' + desc)
  283. try:
  284. open(fn,'w').write(kdata+'\n')
  285. os.chmod(fn,0o400)
  286. msg('Wrote ' + desc)
  287. except:
  288. die(2,'Unable to write ' + desc)
  289. def gen_key(no_unmount=False):
  290. create_wallet_dir()
  291. if not get_insert_status():
  292. die(1,'Removable device not present!')
  293. do_mount()
  294. wipe_existing_key()
  295. create_key()
  296. if not no_unmount:
  297. do_umount()
  298. def remove_wallet_dir():
  299. msg(f'Deleting {wallet_dir!r}')
  300. try: shutil.rmtree(wallet_dir)
  301. except: pass
  302. def create_wallet_dir():
  303. try: os.mkdir(wallet_dir)
  304. except: pass
  305. try: os.stat(wallet_dir)
  306. except: die(2,f'Unable to create wallet directory {wallet_dir!r}')
  307. def setup():
  308. remove_wallet_dir()
  309. gen_key(no_unmount=True)
  310. ss_in = Wallet(in_fmt=mn_fmts[opt.mnemonic_fmt or mn_fmt_dfl])
  311. ss_out = Wallet(ss=ss_in)
  312. ss_out.write_to_file(desc='autosign wallet',outdir=wallet_dir)
  313. def get_insert_status():
  314. if opt.no_insert_check:
  315. return True
  316. try: os.stat(os.path.join('/dev/disk/by-label',part_label))
  317. except: return False
  318. else: return True
  319. def check_wipe_present():
  320. try:
  321. run(['wipe','-v'],stdout=DEVNULL,stderr=DEVNULL,check=True)
  322. except:
  323. die(2,"The 'wipe' utility must be installed before running this program")
  324. async def do_loop():
  325. n,prev_status = 0,False
  326. if not opt.stealth_led:
  327. led.set('standby')
  328. while True:
  329. status = get_insert_status()
  330. if status and not prev_status:
  331. msg('Device insertion detected')
  332. await do_sign()
  333. prev_status = status
  334. if not n % 10:
  335. msg_r(f"\r{' '*17}\rWaiting")
  336. sys.stderr.flush()
  337. time.sleep(1)
  338. msg_r('.')
  339. n += 1
  340. if len(cmd_args) not in (0,1):
  341. opts.usage()
  342. if len(cmd_args) == 1:
  343. cmd = cmd_args[0]
  344. if cmd in ('gen_key','setup'):
  345. globals()[cmd]()
  346. sys.exit(0)
  347. elif cmd != 'wait':
  348. die(1,f'{cmd!r}: unrecognized command')
  349. check_wipe_present()
  350. wfs = get_wallet_files()
  351. def at_exit(exit_val,message='\nCleaning up...'):
  352. if message:
  353. msg(message)
  354. led.stop()
  355. sys.exit(exit_val)
  356. def handler(a,b):
  357. at_exit(1)
  358. signal.signal(signal.SIGTERM,handler)
  359. signal.signal(signal.SIGINT,handler)
  360. from .led import LEDControl
  361. led = LEDControl(enabled=opt.led,simulate=g.test_suite and not os.getenv('MMGEN_TEST_SUITE_AUTOSIGN_LIVE'))
  362. led.set('off')
  363. async def main():
  364. await check_daemons_running()
  365. if len(cmd_args) == 0:
  366. ret = await do_sign()
  367. at_exit(int(not ret),message='')
  368. elif cmd_args[0] == 'wait':
  369. await do_loop()
  370. run_session(main())