autosign.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
  4. # Copyright (C)2013-2023 The MMGen Project <mmgen@tuta.io>
  5. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen
  9. # https://gitlab.com/mmgen/mmgen
  10. """
  11. autosign: Auto-sign MMGen transactions and message files
  12. """
  13. import sys,os,asyncio
  14. from subprocess import run,PIPE,DEVNULL
  15. from collections import namedtuple
  16. from .cfg import Config
  17. from .util import msg,msg_r,ymsg,rmsg,gmsg,bmsg,die,suf,fmt,fmt_list
  18. from .color import yellow,red,orange
  19. from .wallet import Wallet
  20. class AutosignConfig(Config):
  21. _set_ok = ('usr_randchars','_proto','outdir','passwd_file')
  22. class Signable:
  23. class base:
  24. def __init__(self,parent):
  25. self.parent = parent
  26. self.cfg = parent.cfg
  27. self.dir = getattr(parent,self.dir_name)
  28. @property
  29. def unsigned(self):
  30. if not hasattr(self,'_unsigned'):
  31. dirlist = tuple(os.scandir(self.dir))
  32. names = tuple(f.name for f in dirlist)
  33. self._unsigned = tuple(f for f in dirlist
  34. if f.name.endswith('.'+self.rawext)
  35. and f.name[:-len(self.rawext)]+self.sigext not in names)
  36. return self._unsigned
  37. def print_bad_list(self,bad_files):
  38. msg('\n{a}\n{b}'.format(
  39. a = red(f'Failed {self.desc}s:'),
  40. b = ' {}\n'.format('\n '.join(self.gen_bad_list(sorted(bad_files,key=lambda f: f.name))))
  41. ))
  42. class transaction(base):
  43. desc = 'transaction'
  44. rawext = 'rawtx'
  45. sigext = 'sigtx'
  46. dir_name = 'tx_dir'
  47. fail_msg = 'failed to sign'
  48. async def sign(self,f):
  49. from .tx import UnsignedTX
  50. tx1 = UnsignedTX( cfg=self.cfg, filename=f.path )
  51. if tx1.proto.sign_mode == 'daemon':
  52. from .rpc import rpc_init
  53. tx1.rpc = await rpc_init( self.cfg, tx1.proto )
  54. from .tx.sign import txsign
  55. tx2 = await txsign( self.cfg, tx1, self.parent.wallet_files[:], None, None )
  56. if tx2:
  57. tx2.file.write(ask_write=False)
  58. return tx2
  59. else:
  60. return False
  61. def print_summary(self,txs):
  62. if self.cfg.full_summary:
  63. bmsg('\nAutosign summary:\n')
  64. msg_r('\n'.join(tx.info.format(terse=True) for tx in txs))
  65. return
  66. def gen():
  67. for tx in txs:
  68. non_mmgen = [o for o in tx.outputs if not o.mmid]
  69. if non_mmgen:
  70. yield (tx,non_mmgen)
  71. body = list(gen())
  72. if body:
  73. bmsg('\nAutosign summary:')
  74. fs = '{} {} {}'
  75. t_wid,a_wid = 6,44
  76. def gen():
  77. yield fs.format('TX ID ','Non-MMGen outputs'+' '*(a_wid-17),'Amount')
  78. yield fs.format('-'*t_wid, '-'*a_wid, '-'*7)
  79. for tx,non_mmgen in body:
  80. for nm in non_mmgen:
  81. yield fs.format(
  82. tx.txid.fmt( width=t_wid, color=True ) if nm is non_mmgen[0] else ' '*t_wid,
  83. nm.addr.fmt( width=a_wid, color=True ),
  84. nm.amt.hl() + ' ' + yellow(tx.coin))
  85. msg('\n' + '\n'.join(gen()))
  86. else:
  87. msg('\nNo non-MMGen outputs')
  88. def gen_bad_list(self,bad_files):
  89. for f in bad_files:
  90. yield red(f.path)
  91. class message(base):
  92. desc = 'message file'
  93. rawext = 'rawmsg.json'
  94. sigext = 'sigmsg.json'
  95. dir_name = 'msg_dir'
  96. fail_msg = 'failed to sign or signed incompletely'
  97. async def sign(self,f):
  98. from .msg import UnsignedMsg,SignedMsg
  99. m = UnsignedMsg( self.cfg, infile=f.path )
  100. await m.sign( wallet_files=self.parent.wallet_files[:] )
  101. m = SignedMsg( self.cfg, data=m.__dict__ )
  102. m.write_to_file(
  103. outdir = os.path.abspath(self.dir),
  104. ask_overwrite = False )
  105. if m.data.get('failed_sids'):
  106. die('MsgFileFailedSID',f'Failed Seed IDs: {fmt_list(m.data["failed_sids"],fmt="bare")}')
  107. return m
  108. def print_summary(self,messages):
  109. gmsg('\nSigned message files:')
  110. for m in messages:
  111. gmsg(' ' + os.path.join( self.dir, m.signed_filename ))
  112. return
  113. def gen_bad_list(self,bad_files):
  114. for f in bad_files:
  115. sigfile = f.path[:-len(self.rawext)] + self.sigext
  116. yield orange(sigfile) if os.path.exists(sigfile) else red(f.path)
  117. class Autosign:
  118. dfl_mountpoint = os.path.join(os.sep,'mnt','mmgen_autosign')
  119. dfl_wallet_dir = os.path.join(os.sep,'dev','shm','autosign')
  120. disk_label_dir = os.path.join(os.sep,'dev','disk','by-label')
  121. part_label = 'MMGEN_TX'
  122. old_dfl_mountpoint = os.path.join(os.sep,'mnt','tx')
  123. old_dfl_mountpoint_errmsg = f"""
  124. Mountpoint {old_dfl_mountpoint!r} is no longer supported!
  125. Please rename {old_dfl_mountpoint!r} to {dfl_mountpoint!r}
  126. and update your fstab accordingly.
  127. """
  128. mountpoint_errmsg_fs = """
  129. Mountpoint {!r} does not exist or does not point
  130. to a directory! Please create the mountpoint and add an entry
  131. to your fstab as described in this script’s help text.
  132. """
  133. mn_fmts = {
  134. 'mmgen': 'words',
  135. 'bip39': 'bip39',
  136. }
  137. dfl_mn_fmt = 'mmgen'
  138. have_msg_dir = False
  139. def __init__(self,cfg):
  140. if cfg.mnemonic_fmt:
  141. if cfg.mnemonic_fmt not in self.mn_fmts:
  142. die(1,'{!r}: invalid mnemonic format (must be one of: {})'.format(
  143. cfg.mnemonic_fmt,
  144. fmt_list( self.mn_fmts, fmt='no_spc' ) ))
  145. self.cfg = cfg
  146. self.mountpoint = cfg.mountpoint or self.dfl_mountpoint
  147. self.wallet_dir = cfg.wallet_dir or self.dfl_wallet_dir
  148. self.tx_dir = os.path.join( self.mountpoint, 'tx' )
  149. self.msg_dir = os.path.join( self.mountpoint, 'msg' )
  150. self.keyfile = os.path.join( self.mountpoint, 'autosign.key' )
  151. cfg.outdir = self.tx_dir
  152. cfg.passwd_file = self.keyfile
  153. if 'coin' in cfg._uopts:
  154. die(1,'--coin option not supported with this command. Use --coins instead')
  155. if cfg.coins:
  156. self.coins = cfg.coins.upper().split(',')
  157. else:
  158. ymsg('Warning: no coins specified, defaulting to BTC')
  159. self.coins = ['BTC']
  160. async def check_daemons_running(self):
  161. from .protocol import init_proto
  162. for coin in self.coins:
  163. proto = init_proto( self.cfg, coin, testnet=self.cfg.network=='testnet', need_amt=True )
  164. if proto.sign_mode == 'daemon':
  165. self.cfg._util.vmsg(f'Checking {coin} daemon')
  166. from .rpc import rpc_init
  167. from .exception import SocketError
  168. try:
  169. await rpc_init( self.cfg, proto )
  170. except SocketError as e:
  171. die(2,f'{coin} daemon not running or not listening on port {proto.rpc_port}')
  172. @property
  173. def wallet_files(self):
  174. if not hasattr(self,'_wallet_files'):
  175. try:
  176. dirlist = os.listdir(self.wallet_dir)
  177. except:
  178. die(1,f'Cannot open wallet directory {self.wallet_dir!r}. Did you run ‘mmgen-autosign setup’?')
  179. fns = [fn for fn in dirlist if fn.endswith('.mmdat')]
  180. if fns:
  181. self._wallet_files = [os.path.join(self.wallet_dir,fn) for fn in fns]
  182. else:
  183. die(1,'No wallet files present!')
  184. return self._wallet_files
  185. def do_mount(self):
  186. if not os.path.isdir(self.mountpoint):
  187. def do_die(m):
  188. die(1,'\n' + yellow(fmt(m.strip(),indent=' ')))
  189. if os.path.isdir(self.old_dfl_mountpoint):
  190. do_die(self.old_dfl_mountpoint_errmsg)
  191. else:
  192. do_die(self.mountpoint_errmsg_fs.format(self.mountpoint))
  193. if not os.path.ismount(self.mountpoint):
  194. if run( ['mount',self.mountpoint], stderr=DEVNULL, stdout=DEVNULL ).returncode == 0:
  195. msg(f'Mounting {self.mountpoint!r}')
  196. self.have_msg_dir = os.path.isdir(self.msg_dir)
  197. from stat import S_ISDIR,S_IWUSR,S_IRUSR
  198. for cdir in [self.tx_dir] + ([self.msg_dir] if self.have_msg_dir else []):
  199. try:
  200. ds = os.stat(cdir)
  201. assert S_ISDIR(ds.st_mode), f'{cdir!r} is not a directory!'
  202. assert ds.st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR, f'{cdir!r} is not read/write for this user!'
  203. except:
  204. die(1,f'{cdir!r} missing or not read/writable by user!')
  205. def do_umount(self):
  206. if os.path.ismount(self.mountpoint):
  207. run( ['sync'], check=True )
  208. msg(f'Unmounting {self.mountpoint}')
  209. run( ['umount',self.mountpoint], check=True )
  210. def decrypt_wallets(self):
  211. msg(f'Unlocking wallet{suf(self.wallet_files)} with key from {self.cfg.passwd_file!r}')
  212. fails = 0
  213. for wf in self.wallet_files:
  214. try:
  215. Wallet( self.cfg, wf, ignore_in_fmt=True )
  216. except SystemExit as e:
  217. if e.code != 0:
  218. fails += 1
  219. return False if fails else True
  220. async def sign_all(self,target_name):
  221. target = getattr(Signable,target_name)(self)
  222. if target.unsigned:
  223. good = []
  224. bad = []
  225. for f in target.unsigned:
  226. ret = None
  227. try:
  228. ret = await target.sign(f)
  229. except Exception as e:
  230. ymsg(f'An error occurred with {target.desc} {f.name!r}:\n {type(e).__name__}: {e!s}')
  231. except:
  232. ymsg(f'An error occurred with {target.desc} {f.name!r}')
  233. good.append(ret) if ret else bad.append(f)
  234. self.cfg._util.qmsg('')
  235. await asyncio.sleep(0.3)
  236. msg(f'{len(good)} {target.desc}{suf(good)} signed')
  237. if bad:
  238. rmsg(f'{len(bad)} {target.desc}{suf(bad)} {target.fail_msg}')
  239. if good and not self.cfg.no_summary:
  240. target.print_summary(good)
  241. if bad:
  242. target.print_bad_list(bad)
  243. return not bad
  244. else:
  245. msg(f'No unsigned {target.desc}s')
  246. await asyncio.sleep(0.5)
  247. return True
  248. async def do_sign(self):
  249. if not self.cfg.stealth_led:
  250. self.led.set('busy')
  251. self.do_mount()
  252. key_ok = self.decrypt_wallets()
  253. if key_ok:
  254. if self.cfg.stealth_led:
  255. self.led.set('busy')
  256. ret1 = await self.sign_all('transaction')
  257. ret2 = await self.sign_all('message') if self.have_msg_dir else True
  258. ret = ret1 and ret2
  259. self.do_umount()
  260. self.led.set(('standby','off','error')[(not ret)*2 or bool(self.cfg.stealth_led)])
  261. return ret
  262. else:
  263. msg('Password is incorrect!')
  264. self.do_umount()
  265. if not self.cfg.stealth_led:
  266. self.led.set('error')
  267. return False
  268. def wipe_existing_key(self):
  269. try: os.stat(self.keyfile)
  270. except: pass
  271. else:
  272. from .fileutil import shred_file
  273. msg(f'\nShredding existing key {self.keyfile!r}')
  274. shred_file( self.keyfile, verbose=self.cfg.verbose )
  275. def create_key(self):
  276. kdata = os.urandom(32).hex()
  277. desc = f'key file {self.keyfile!r}'
  278. msg('Creating ' + desc)
  279. try:
  280. with open(self.keyfile,'w') as fp:
  281. fp.write(kdata+'\n')
  282. os.chmod(self.keyfile,0o400)
  283. msg('Wrote ' + desc)
  284. except:
  285. die(2,'Unable to write ' + desc)
  286. def gen_key(self,no_unmount=False):
  287. self.create_wallet_dir()
  288. if not self.get_insert_status():
  289. die(1,'Removable device not present!')
  290. self.do_mount()
  291. self.wipe_existing_key()
  292. self.create_key()
  293. if not no_unmount:
  294. self.do_umount()
  295. def remove_wallet_dir(self):
  296. msg(f'Deleting {self.wallet_dir!r}')
  297. import shutil
  298. try: shutil.rmtree(self.wallet_dir)
  299. except: pass
  300. def create_wallet_dir(self):
  301. try: os.mkdir(self.wallet_dir)
  302. except: pass
  303. try: os.stat(self.wallet_dir)
  304. except: die(2,f'Unable to create wallet directory {self.wallet_dir!r}')
  305. def setup(self):
  306. self.remove_wallet_dir()
  307. self.gen_key(no_unmount=True)
  308. ss_in = Wallet( self.cfg, in_fmt=self.mn_fmts[self.cfg.mnemonic_fmt or self.dfl_mn_fmt] )
  309. ss_out = Wallet( self.cfg, ss=ss_in )
  310. ss_out.write_to_file( desc='autosign wallet', outdir=self.wallet_dir )
  311. def get_insert_status(self):
  312. if self.cfg.no_insert_check:
  313. return True
  314. try: os.stat(os.path.join( self.disk_label_dir, self.part_label ))
  315. except: return False
  316. else: return True
  317. async def do_loop(self):
  318. n,prev_status = 0,False
  319. if not self.cfg.stealth_led:
  320. self.led.set('standby')
  321. while True:
  322. status = self.get_insert_status()
  323. if status and not prev_status:
  324. msg('Device insertion detected')
  325. await self.do_sign()
  326. prev_status = status
  327. if not n % 10:
  328. msg_r(f"\r{' '*17}\rWaiting")
  329. sys.stderr.flush()
  330. await asyncio.sleep(1)
  331. msg_r('.')
  332. n += 1
  333. def at_exit(self,exit_val,message=None):
  334. if message:
  335. msg(message)
  336. self.led.stop()
  337. sys.exit(int(exit_val))
  338. def init_exit_handler(self):
  339. def handler(arg1,arg2):
  340. self.at_exit(1,'\nCleaning up...')
  341. import signal
  342. signal.signal( signal.SIGTERM, handler )
  343. signal.signal( signal.SIGINT, handler )
  344. def init_led(self):
  345. from .led import LEDControl
  346. self.led = LEDControl(
  347. enabled = self.cfg.led,
  348. simulate = os.getenv('MMGEN_TEST_SUITE_AUTOSIGN_LED_SIMULATE') )
  349. self.led.set('off')