autosign.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 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-wallet
  9. # https://gitlab.com/mmgen/mmgen-wallet
  10. """
  11. autosign: Autosign MMGen transactions, message files and XMR wallet output files
  12. """
  13. import sys, os, asyncio
  14. from stat import S_IWUSR, S_IRUSR
  15. from pathlib import Path
  16. from subprocess import run, PIPE, DEVNULL
  17. from .cfg import Config
  18. from .util import msg, msg_r, ymsg, rmsg, gmsg, bmsg, die, suf, fmt, fmt_list, is_int, have_sudo, capfirst
  19. from .color import yellow, red, orange, brown, blue, gray
  20. from .wallet import Wallet, get_wallet_cls
  21. from .addrlist import AddrIdxList
  22. from .filename import find_file_in_dir
  23. from .fileutil import shred_file
  24. from .ui import keypress_confirm
  25. def SwapMgr(*args, **kwargs):
  26. match sys.platform:
  27. case 'linux':
  28. return SwapMgrLinux(*args, **kwargs)
  29. case 'darwin':
  30. return SwapMgrMacOS(*args, **kwargs)
  31. class SwapMgrBase:
  32. def __init__(self, cfg, *, ignore_zram=False):
  33. self.cfg = cfg
  34. self.ignore_zram = ignore_zram
  35. self.desc = 'disk swap' if ignore_zram else 'swap'
  36. def enable(self, *, quiet=False):
  37. ret = self.do_enable()
  38. if not quiet:
  39. self.cfg._util.qmsg(
  40. f'{capfirst(self.desc)} successfully enabled' if ret else
  41. f'{capfirst(self.desc)} is already enabled' if ret is None else
  42. f'Could not enable {self.desc}')
  43. return ret
  44. def disable(self, *, quiet=False):
  45. self.cfg._util.qmsg_r(f'Attempting to disable {self.desc}...')
  46. ret = self.do_disable()
  47. self.cfg._util.qmsg('success')
  48. if not quiet:
  49. self.cfg._util.qmsg(
  50. f'{capfirst(self.desc)} successfully disabled ({fmt_list(ret, fmt="no_quotes")})'
  51. if ret and isinstance(ret, list) else
  52. f'{capfirst(self.desc)} successfully disabled' if ret else
  53. f'No active {self.desc}')
  54. return ret
  55. def process_cmds(self, op, cmds):
  56. if not cmds:
  57. return
  58. if have_sudo(silent=True) and not self.cfg.test_suite:
  59. for cmd in cmds:
  60. run(cmd.split(), check=True)
  61. else:
  62. pre = 'failure\n' if op == 'disable' else ''
  63. fs = blue('{a} {b} manually by executing the following command{c}:\n{d}')
  64. post = orange('[To prevent this message in the future, enable sudo without a password]')
  65. m = pre + fs.format(
  66. a = 'Please disable' if op == 'disable' else 'Enable',
  67. b = self.desc,
  68. c = suf(cmds),
  69. d = fmt_list(cmds, indent=' ', fmt='col')) + '\n' + post
  70. msg(m)
  71. if not self.cfg.test_suite:
  72. sys.exit(1)
  73. class SwapMgrLinux(SwapMgrBase):
  74. def get_active(self):
  75. for cmd in ('/sbin/swapon', 'swapon'):
  76. try:
  77. cp = run([cmd, '--show=NAME', '--noheadings'], stdout=PIPE, text=True, check=True)
  78. break
  79. except Exception:
  80. if cmd == 'swapon':
  81. raise
  82. res = cp.stdout.splitlines()
  83. return [e for e in res if not e.startswith('/dev/zram')] if self.ignore_zram else res
  84. def do_enable(self):
  85. if ret := self.get_active():
  86. ymsg(f'Warning: {self.desc} is already enabled: ({fmt_list(ret, fmt="no_quotes")})')
  87. self.process_cmds('enable', ['sudo swapon --all'])
  88. return True
  89. def do_disable(self):
  90. swapdevs = self.get_active()
  91. if not swapdevs:
  92. return None
  93. self.process_cmds('disable', [f'sudo swapoff {swapdev}' for swapdev in swapdevs])
  94. return swapdevs
  95. class SwapMgrMacOS(SwapMgrBase):
  96. def get_active(self):
  97. cmd = 'launchctl print system/com.apple.dynamic_pager'
  98. return run(cmd.split(), stdout=DEVNULL, stderr=DEVNULL).returncode == 0
  99. def _do_action(self, active, op, cmd):
  100. if self.get_active() is active:
  101. return None
  102. else:
  103. cmd = f'sudo launchctl {cmd} -w /System/Library/LaunchDaemons/com.apple.dynamic_pager.plist'
  104. self.process_cmds(op, [cmd])
  105. return True
  106. def do_enable(self):
  107. return self._do_action(active=True, op='enable', cmd='load')
  108. def do_disable(self):
  109. return self._do_action(active=False, op='disable', cmd='unload')
  110. class Signable:
  111. non_xmr_signables = (
  112. 'transaction',
  113. 'automount_transaction',
  114. 'message')
  115. xmr_signables = ( # order is important!
  116. 'xmr_wallet_outputs_file', # import XMR wallet outputs BEFORE signing transactions
  117. 'xmr_transaction')
  118. class base:
  119. clean_all = False
  120. multiple_ok = True
  121. action_desc = 'signed'
  122. fail_msg = 'failed to sign'
  123. def __init__(self, parent):
  124. self.parent = parent
  125. self.cfg = parent.cfg
  126. self.dir = getattr(parent, self.dir_name)
  127. self.name = type(self).__name__
  128. @property
  129. def unsigned(self):
  130. return self._unprocessed('_unsigned', self.rawext, self.sigext)
  131. def _unprocessed(self, attrname, rawext, sigext):
  132. if not hasattr(self, attrname):
  133. dirlist = sorted(self.dir.iterdir())
  134. names = {f.name for f in dirlist}
  135. setattr(
  136. self,
  137. attrname,
  138. tuple(f for f in dirlist
  139. if f.name.endswith('.' + rawext)
  140. and f.name[:-len(rawext)] + sigext not in names))
  141. return getattr(self, attrname)
  142. def print_bad_list(self, bad_files):
  143. msg('\n{a}\n{b}'.format(
  144. a = red(f'Failed {self.desc}s:'),
  145. b = ' {}\n'.format('\n '.join(
  146. self.gen_bad_list(sorted(bad_files, key=lambda f: f.name))))))
  147. def gen_bad_list(self, bad_files):
  148. for f in bad_files:
  149. yield red(f.name)
  150. class transaction(base):
  151. desc = 'non-automount transaction'
  152. dir_name = 'tx_dir'
  153. rawext = 'rawtx'
  154. sigext = 'sigtx'
  155. automount = False
  156. async def sign(self, f):
  157. from .tx import UnsignedTX
  158. tx1 = UnsignedTX(
  159. cfg = self.cfg,
  160. filename = f,
  161. automount = self.automount)
  162. if tx1.proto.coin == 'XMR':
  163. ctx = Signable.xmr_compat_transaction(self.parent)
  164. for k in ('desc', 'print_summary', 'print_bad_list'):
  165. setattr(self, k, getattr(ctx, k))
  166. return await ctx.sign(f, compat_call=True)
  167. if tx1.proto.sign_mode == 'daemon':
  168. from .rpc import rpc_init
  169. tx1.rpc = await rpc_init(self.cfg, tx1.proto, ignore_wallet=True)
  170. from .tx.keys import TxKeys
  171. tx2 = await tx1.sign(
  172. TxKeys(
  173. self.cfg,
  174. tx1,
  175. seedfiles = self.parent.wallet_files[:],
  176. keylist = self.parent.keylist,
  177. passwdfile = str(self.parent.keyfile),
  178. autosign = True).keys)
  179. if tx2:
  180. tx2.file.write(ask_write=False, outdir=self.dir)
  181. return tx2
  182. else:
  183. return False
  184. def print_summary(self, signables):
  185. if self.cfg.full_summary:
  186. bmsg('\nAutosign summary:\n')
  187. msg_r('\n'.join(tx.info.format(terse=True) for tx in signables))
  188. return
  189. def gen():
  190. for tx in signables:
  191. non_mmgen = [o for o in tx.outputs if not o.mmid]
  192. if non_mmgen:
  193. yield (tx, non_mmgen)
  194. body = list(gen())
  195. if body:
  196. bmsg('\nAutosign summary:')
  197. fs = '{} {} {}'
  198. t_wid, a_wid = 6, 44
  199. def gen():
  200. yield fs.format('TX ID ', 'Non-MMGen outputs'+' '*(a_wid-17), 'Amount')
  201. yield fs.format('-'*t_wid, '-'*a_wid, '-'*7)
  202. for tx, non_mmgen in body:
  203. for nm in non_mmgen:
  204. yield fs.format(
  205. tx.txid.fmt(t_wid, color=True) if nm is non_mmgen[0] else ' '*t_wid,
  206. nm.addr.fmt(nm.addr.view_pref, a_wid, color=True),
  207. nm.amt.hl() + ' ' + yellow(tx.coin))
  208. msg('\n' + '\n'.join(gen()))
  209. else:
  210. msg('\nNo non-MMGen outputs')
  211. class automount_transaction(transaction):
  212. desc = 'automount transaction'
  213. dir_name = 'txauto_dir'
  214. rawext = 'arawtx'
  215. sigext = 'asigtx'
  216. subext = 'asubtx'
  217. multiple_ok = False
  218. automount = True
  219. @property
  220. def unsubmitted(self):
  221. return self._unprocessed('_unsubmitted', self.sigext, self.subext)
  222. @property
  223. def unsubmitted_raw(self):
  224. return self._unprocessed('_unsubmitted_raw', self.rawext, self.subext)
  225. unsent = unsubmitted
  226. unsent_raw = unsubmitted_raw
  227. @property
  228. def submitted(self):
  229. return self._processed('_submitted', self.subext)
  230. def _processed(self, attrname, ext):
  231. if not hasattr(self, attrname):
  232. setattr(self, attrname, tuple(f for f in sorted(self.dir.iterdir())
  233. if f.name.endswith('.' + ext)))
  234. return getattr(self, attrname)
  235. def die_wrong_num_txs(self, tx_type, *, msg=None, desc=None, show_dir=False):
  236. match len(getattr(self, tx_type)): # num_txs
  237. case 0: subj, suf, pred = ('No', 's', 'present')
  238. case 1: subj, suf, pred = ('One', '', 'already present')
  239. case _: subj, suf, pred = ('More than one', '', 'already present')
  240. die('AutosignTXError', '{m}{a} {b} transaction{c} {d} {e}!'.format(
  241. m = msg + '\n' if msg else '',
  242. a = subj,
  243. b = desc or tx_type,
  244. c = suf,
  245. d = pred,
  246. e = f'in ‘{getattr(self.parent, self.dir_name)}’'
  247. if show_dir else 'on removable device'))
  248. def check_create_ok(self):
  249. if len(self.unsigned):
  250. self.die_wrong_num_txs('unsigned', msg='Cannot create transaction')
  251. if len(self.unsent):
  252. die('AutosignTXError', 'Cannot create transaction: you have an unsent transaction')
  253. def get_unsubmitted(self, tx_type='unsubmitted'):
  254. if len(self.unsubmitted) == 1:
  255. return self.unsubmitted[0]
  256. else:
  257. self.die_wrong_num_txs(tx_type)
  258. def get_unsent(self):
  259. return self.get_unsubmitted('unsent')
  260. def get_submitted(self):
  261. if len(self.submitted) == 0:
  262. self.die_wrong_num_txs('submitted')
  263. else:
  264. return self.submitted
  265. def get_abortable(self):
  266. if len(self.unsent_raw) != 1:
  267. self.die_wrong_num_txs('unsent_raw', desc='unsent')
  268. if len(self.unsent) > 1:
  269. self.die_wrong_num_txs('unsent')
  270. if self.unsent:
  271. if self.unsent[0].stem != self.unsent_raw[0].stem:
  272. die(1, f'{self.unsent[0]}, {self.unsent_raw[0]}: file mismatch')
  273. return self.unsent_raw + self.unsent
  274. def shred_abortable(self):
  275. files = self.get_abortable() # raises AutosignTXError if no unsent TXs available
  276. keypress_confirm(
  277. self.cfg,
  278. 'The following file{} will be securely deleted:\n{}\nOK?'.format(
  279. suf(files),
  280. fmt_list(map(str, files), fmt='col', indent=' ')),
  281. do_exit = True)
  282. for fn in files:
  283. msg(f'Shredding file ‘{fn}’')
  284. shred_file(self.cfg, fn, iterations=15)
  285. sys.exit(0)
  286. async def get_last_sent(self):
  287. return await self.get_last_created(
  288. # compat fallback - ‘sent_timestamp’ attr is missing in some old TX files:
  289. sort_key = lambda x: x.sent_timestamp or x.timestamp)
  290. async def get_last_created(self, *, sort_key=lambda x: x.timestamp):
  291. from .tx import CompletedTX
  292. fns = [f for f in self.dir.iterdir() if f.name.endswith(self.subext)]
  293. files = sorted(
  294. [await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True)
  295. for txfile in fns],
  296. key = sort_key)
  297. return files[-1]
  298. class xmr_signable: # mixin class
  299. automount = True
  300. summary_footer = ''
  301. def need_daemon_restart(self, m, new_idx):
  302. old_idx = self.parent.xmr_cur_wallet_idx
  303. self.parent.xmr_cur_wallet_idx = new_idx
  304. return old_idx != new_idx or m.wd.state != 'ready'
  305. def print_summary(self, signables):
  306. bmsg('\nAutosign summary:')
  307. msg('\n'.join(s.get_info(indent=' ') for s in signables) + self.summary_footer)
  308. class xmr_transaction(xmr_signable, automount_transaction):
  309. desc = 'Monero non-compat transaction'
  310. dir_name = 'xmr_tx_dir'
  311. rawext = 'rawtx'
  312. sigext = 'sigtx'
  313. subext = 'subtx'
  314. async def sign(self, f, compat_call=False):
  315. from . import xmrwallet
  316. from .xmrwallet.file.tx import MoneroMMGenTX
  317. tx1 = MoneroMMGenTX.Completed(self.parent.xmrwallet_cfg, f)
  318. m = xmrwallet.op(
  319. 'sign',
  320. self.parent.xmrwallet_cfg,
  321. infile = str(self.parent.wallet_files[0]), # MMGen wallet file
  322. wallets = str(tx1.src_wallet_idx),
  323. compat_call = compat_call)
  324. tx2 = await m.main(f, restart_daemon=self.need_daemon_restart(m, tx1.src_wallet_idx))
  325. tx2.write(ask_write=False)
  326. return tx2
  327. class xmr_compat_transaction(xmr_transaction):
  328. desc = 'Monero compat transaction'
  329. dir_name = 'txauto_dir'
  330. rawext = 'arawtx'
  331. sigext = 'asigtx'
  332. subext = 'asubtx'
  333. class xmr_wallet_outputs_file(xmr_signable, base):
  334. desc = 'Monero wallet outputs file'
  335. dir_name = 'xmr_outputs_dir'
  336. rawext = 'raw'
  337. sigext = 'sig'
  338. clean_all = True
  339. summary_footer = '\n'
  340. @property
  341. def unsigned(self):
  342. import json
  343. return tuple(
  344. f for f in super().unsigned
  345. if not json.loads(f.read_text())['MoneroMMGenWalletOutputsFile']['data']['imported'])
  346. async def sign(self, f):
  347. from . import xmrwallet
  348. wallet_idx = xmrwallet.op_cls('wallet').get_idx_from_fn(f)
  349. m = xmrwallet.op(
  350. 'import_outputs',
  351. self.parent.xmrwallet_cfg,
  352. infile = str(self.parent.wallet_files[0]), # MMGen wallet file
  353. wallets = str(wallet_idx))
  354. obj = await m.main(f, wallet_idx, restart_daemon=self.need_daemon_restart(m, wallet_idx))
  355. obj.write(quiet=not obj.data.sign)
  356. self.action_desc = 'imported and signed' if obj.data.sign else 'imported'
  357. return obj
  358. class message(base):
  359. desc = 'message file'
  360. dir_name = 'msg_dir'
  361. rawext = 'rawmsg.json'
  362. sigext = 'sigmsg.json'
  363. fail_msg = 'failed to sign or signed incompletely'
  364. async def sign(self, f):
  365. from .msg import UnsignedMsg, SignedMsg
  366. m = UnsignedMsg(self.cfg, infile=f)
  367. await m.sign(wallet_files=self.parent.wallet_files[:], passwd_file=str(self.parent.keyfile))
  368. m = SignedMsg(self.cfg, data=m.__dict__)
  369. m.write_to_file(
  370. outdir = self.dir.resolve(),
  371. ask_overwrite = False)
  372. if m.data.get('failed_sids'):
  373. die(
  374. 'MsgFileFailedSID',
  375. f'Failed Seed IDs: {fmt_list(m.data["failed_sids"], fmt="bare")}')
  376. return m
  377. def print_summary(self, signables):
  378. gmsg('\nSigned message files:')
  379. for message in signables:
  380. gmsg(' ' + message.signed_filename)
  381. def gen_bad_list(self, bad_files):
  382. for f in bad_files:
  383. sigfile = f.parent / (f.name[:-len(self.rawext)] + self.sigext)
  384. yield orange(sigfile.name) if sigfile.exists() else red(f.name)
  385. class Autosign:
  386. dev_label = 'MMGEN_TX'
  387. linux_mount_subdir = 'mmgen_autosign'
  388. macOS_ramdisk_name = 'AutosignRamDisk'
  389. wallet_subdir = 'autosign'
  390. linux_blkid_cmd = 'sudo blkid -s LABEL -o value'
  391. keylist_fn = 'keylist.mmenc'
  392. cmds = ('setup', 'xmr_setup', 'sign', 'wait')
  393. util_cmds = (
  394. 'gen_key',
  395. 'macos_ramdisk_setup',
  396. 'macos_ramdisk_delete',
  397. 'enable_swap',
  398. 'disable_swap',
  399. 'clean',
  400. 'wipe_key')
  401. mn_fmts = {
  402. 'mmgen': 'words',
  403. 'bip39': 'bip39'}
  404. dfl_mn_fmt = 'mmgen'
  405. non_xmr_dirs = {
  406. 'tx_dir': 'tx',
  407. 'txauto_dir': 'txauto',
  408. 'msg_dir': 'msg'}
  409. xmr_dirs = {
  410. 'xmr_dir': 'xmr',
  411. 'xmr_tx_dir': 'xmr/tx',
  412. 'xmr_outputs_dir': 'xmr/outputs'}
  413. have_xmr = False
  414. xmr_only = False
  415. def init_fixup(self): # see test/overlay/fakemods/mmgen/autosign.py
  416. pass
  417. def __init__(self, cfg, *, cmd=None):
  418. if cfg.mnemonic_fmt:
  419. if cfg.mnemonic_fmt not in self.mn_fmts:
  420. die(1, '{!r}: invalid mnemonic format (must be one of: {})'.format(
  421. cfg.mnemonic_fmt,
  422. fmt_list(self.mn_fmts, fmt='no_spc')))
  423. match sys.platform:
  424. case 'linux':
  425. self.dfl_mountpoint = f'/mnt/{self.linux_mount_subdir}'
  426. self.dfl_shm_dir = '/dev/shm'
  427. # linux-only attrs:
  428. self.old_dfl_mountpoint = '/mnt/tx'
  429. self.old_dfl_mountpoint_errmsg = f"""
  430. Mountpoint ‘{self.old_dfl_mountpoint}’ is no longer supported!
  431. Please rename ‘{self.old_dfl_mountpoint}’ to ‘{self.dfl_mountpoint}’
  432. and update your fstab accordingly.
  433. """
  434. self.mountpoint_errmsg_fs = """
  435. Mountpoint ‘{}’ does not exist or does not point
  436. to a directory! Please create the mountpoint and add an entry
  437. to your fstab as described in this script’s help text.
  438. """
  439. case 'darwin':
  440. self.dfl_mountpoint = f'/Volumes/{self.dev_label}'
  441. self.dfl_shm_dir = f'/Volumes/{self.macOS_ramdisk_name}'
  442. self.cfg = cfg
  443. self.dfl_wallet_dir = f'{self.dfl_shm_dir}/{self.wallet_subdir}'
  444. self.mountpoint = Path(cfg.mountpoint or self.dfl_mountpoint)
  445. self.shm_dir = Path(self.dfl_shm_dir)
  446. self.wallet_dir = Path(cfg.wallet_dir or self.dfl_wallet_dir)
  447. match sys.platform:
  448. case 'linux':
  449. self.mount_cmd = f'mount {self.mountpoint}'
  450. self.umount_cmd = f'umount {self.mountpoint}'
  451. case 'darwin':
  452. self.mount_cmd = f'diskutil mount {self.dev_label}'
  453. self.umount_cmd = f'diskutil eject {self.dev_label}'
  454. self.init_fixup()
  455. if sys.platform == 'darwin': # test suite uses ‘fixed-up’ shm_dir
  456. from .platform.darwin.util import MacOSRamDisk
  457. self.ramdisk = MacOSRamDisk(
  458. cfg,
  459. self.macOS_ramdisk_name,
  460. self._get_macOS_ramdisk_size(),
  461. path = self.shm_dir)
  462. self.keyfile = self.mountpoint / 'autosign.key'
  463. if any(k in cfg._uopts for k in ('help', 'longhelp')):
  464. return
  465. self.coins = cfg.coins.upper().split(',') if cfg.coins else []
  466. if cfg.xmrwallets and not 'XMR' in self.coins:
  467. self.coins.append('XMR')
  468. if not self.coins and cmd in self.cmds:
  469. ymsg('Warning: no coins specified, defaulting to BTC')
  470. self.coins = ['BTC']
  471. if 'XMR' in self.coins:
  472. self.have_xmr = True
  473. if len(self.coins) == 1:
  474. self.xmr_only = True
  475. self.xmr_cur_wallet_idx = None
  476. self.dirs = {}
  477. self.signables = ()
  478. if not self.xmr_only:
  479. self.dirs |= self.non_xmr_dirs
  480. self.signables += Signable.non_xmr_signables
  481. if self.have_xmr:
  482. self.dirs |= self.xmr_dirs | (
  483. {'txauto_dir': 'txauto'} if cfg.xmrwallet_compat and self.xmr_only else {})
  484. self.signables = (
  485. Signable.xmr_signables # xmr_wallet_outputs_file must be signed before XMR TXs
  486. + (('automount_transaction',) if cfg.xmrwallet_compat and self.xmr_only else ())
  487. + self.signables) # self.signables could contain compat XMR TXs
  488. for name, path in self.dirs.items():
  489. setattr(self, name, self.mountpoint / path)
  490. self.swap = SwapMgr(self.cfg, ignore_zram=True)
  491. async def check_daemons_running(self):
  492. from .protocol import init_proto
  493. for coin in self.coins:
  494. proto = init_proto(self.cfg, coin, network=self.cfg.network, need_amt=True)
  495. if proto.sign_mode == 'daemon':
  496. self.cfg._util.vmsg(f'Checking {coin} daemon')
  497. from .rpc import rpc_init
  498. from .exception import SocketError
  499. try:
  500. await rpc_init(self.cfg, proto, ignore_wallet=True)
  501. except SocketError as e:
  502. from .daemon import CoinDaemon
  503. d = CoinDaemon(self.cfg, proto=proto, test_suite=self.cfg.test_suite)
  504. die(2,
  505. f'\n{e}\nIs the {d.coind_name} daemon ({d.exec_fn}) running '
  506. + 'and listening on the correct port?')
  507. @property
  508. def wallet_files(self):
  509. if not hasattr(self, '_wallet_files'):
  510. try:
  511. dirlist = self.wallet_dir.iterdir()
  512. except:
  513. die(1,
  514. f'Cannot open wallet directory ‘{self.wallet_dir}’. '
  515. 'Did you run ‘mmgen-autosign setup’?')
  516. self._wallet_files = [f for f in dirlist if f.suffix == '.mmdat']
  517. if not self._wallet_files:
  518. die(1, 'No wallet files present!')
  519. return self._wallet_files
  520. def do_mount(self, *, silent=False, verbose=False):
  521. def check_or_create(dirname):
  522. path = getattr(self, dirname)
  523. if path.is_dir():
  524. if not path.stat().st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR:
  525. die(1, f'‘{path}’ is not read/write for this user!')
  526. elif path.exists():
  527. die(1, f'‘{path}’ is not a directory!')
  528. elif path.is_symlink():
  529. die(1, f'‘{path}’ is a symlink not pointing to a directory!')
  530. else:
  531. msg(f'Creating ‘{path}’')
  532. path.mkdir(parents=True)
  533. if sys.platform == 'linux' and not self.mountpoint.is_dir():
  534. def do_die(m):
  535. die(1, '\n' + yellow(fmt(m.strip(), indent=' ')))
  536. if Path(self.old_dfl_mountpoint).is_dir():
  537. do_die(self.old_dfl_mountpoint_errmsg)
  538. else:
  539. do_die(self.mountpoint_errmsg_fs.format(self.mountpoint))
  540. if not self.mountpoint.is_mount():
  541. redir = None if verbose else DEVNULL
  542. if run(self.mount_cmd.split(), stderr=redir, stdout=redir).returncode == 0:
  543. if not silent:
  544. msg(gray(f'Mounting ‘{self.mountpoint}’'))
  545. else:
  546. die(1, f'Unable to mount device ‘{self.dev_label}’ at ‘{self.mountpoint}’')
  547. for dirname in self.dirs:
  548. check_or_create(dirname)
  549. def do_umount(self, *, silent=False, verbose=False):
  550. if self.mountpoint.is_mount():
  551. run(['sync'], check=True)
  552. if not silent:
  553. msg(gray(f'Unmounting ‘{self.mountpoint}’'))
  554. redir = None if verbose else DEVNULL
  555. run(self.umount_cmd.split(), stdout=redir, check=True)
  556. if not silent:
  557. bmsg('It is now safe to extract the removable device')
  558. def decrypt_wallets(self):
  559. msg(f'Unlocking wallet{suf(self.wallet_files)} with key from ‘{self.keyfile}’')
  560. fails = 0
  561. for wf in self.wallet_files:
  562. try:
  563. Wallet(self.cfg, fn=wf, ignore_in_fmt=True, passwd_file=str(self.keyfile))
  564. except SystemExit as e:
  565. if e.code != 0:
  566. fails += 1
  567. return not fails
  568. async def sign_all(self, target_name):
  569. target = getattr(Signable, target_name)(self)
  570. if target.unsigned:
  571. good = []
  572. bad = []
  573. if len(target.unsigned) > 1 and not target.multiple_ok:
  574. ymsg(f'Autosign error: only one unsigned {target.desc} transaction allowed at a time!')
  575. target.print_bad_list(target.unsigned)
  576. return False
  577. for f in target.unsigned:
  578. ret = None
  579. try:
  580. ret = await target.sign(f)
  581. except Exception as e:
  582. ymsg('An error occurred with {} ‘{}’:\n {}: ‘{}’'.format(
  583. target.desc, f.name, type(e).__name__, e))
  584. except:
  585. ymsg('An error occurred with {} ‘{}’'.format(target.desc, f.name))
  586. good.append(ret) if ret else bad.append(f)
  587. self.cfg._util.qmsg('')
  588. await asyncio.sleep(0.3)
  589. msg(brown(f'{len(good)} {target.desc}{suf(good)} {target.action_desc}'))
  590. if bad:
  591. rmsg(f'{len(bad)} {target.desc}{suf(bad)} {target.fail_msg}')
  592. if good and not self.cfg.no_summary:
  593. target.print_summary(good)
  594. if bad:
  595. target.print_bad_list(bad)
  596. return not bad
  597. else:
  598. return f'No unsigned {target.desc}s'
  599. async def do_sign(self):
  600. if not self.cfg.stealth_led:
  601. self.led.set('busy')
  602. self.do_mount()
  603. key_ok = self.decrypt_wallets()
  604. self.init_non_mmgen_keys()
  605. if key_ok:
  606. if self.cfg.stealth_led:
  607. self.led.set('busy')
  608. ret = [await self.sign_all(signable) for signable in self.signables]
  609. for val in ret:
  610. if isinstance(val, str):
  611. msg(val)
  612. if self.cfg.test_suite_autosign_threaded:
  613. await asyncio.sleep(0.3)
  614. self.do_umount()
  615. self.led.set('error' if not all(ret) else 'off' if self.cfg.stealth_led else 'standby')
  616. return all(ret)
  617. else:
  618. msg('Password is incorrect!')
  619. self.do_umount()
  620. if not self.cfg.stealth_led:
  621. self.led.set('error')
  622. return False
  623. def wipe_encryption_key(self):
  624. if self.keyfile.exists():
  625. ymsg(f'Shredding wallet encryption key ‘{self.keyfile}’')
  626. shred_file(self.cfg, self.keyfile)
  627. else:
  628. gmsg('No wallet encryption key on removable device')
  629. def create_key(self):
  630. desc = f'key file ‘{self.keyfile}’'
  631. msg('Creating ' + desc)
  632. try:
  633. self.keyfile.write_text(os.urandom(32).hex())
  634. self.keyfile.chmod(0o400)
  635. except:
  636. die(2, 'Unable to write ' + desc)
  637. msg('Wrote ' + desc)
  638. def gen_key(self, *, no_unmount=False):
  639. if not self.device_inserted:
  640. die(1, 'Removable device not present!')
  641. self.do_mount()
  642. self.wipe_encryption_key()
  643. self.create_key()
  644. if not no_unmount:
  645. self.do_umount()
  646. def macos_ramdisk_setup(self):
  647. self.ramdisk.create()
  648. def macos_ramdisk_delete(self):
  649. self.ramdisk.destroy()
  650. def _get_macOS_ramdisk_size(self):
  651. from .platform.darwin.util import MacOSRamDisk, warn_ramdisk_too_small
  652. # allow 1MB for each Monero wallet
  653. xmr_size = len(AddrIdxList(fmt_str=self.cfg.xmrwallets)) if self.cfg.xmrwallets else 0
  654. calc_size = xmr_size + 1
  655. usr_size = self.cfg.macos_ramdisk_size or self.cfg.macos_autosign_ramdisk_size
  656. if is_int(usr_size):
  657. usr_size = int(usr_size)
  658. else:
  659. die(1, f'{usr_size}: invalid user-specified macOS ramdisk size (not an integer)')
  660. min_size = MacOSRamDisk.min_size
  661. size = max(usr_size, calc_size, min_size)
  662. if usr_size and usr_size < min_size:
  663. warn_ramdisk_too_small(usr_size, min_size)
  664. return size
  665. def setup(self):
  666. def remove_wallet_dir():
  667. msg(f'Deleting ‘{self.wallet_dir}’')
  668. import shutil
  669. try:
  670. shutil.rmtree(self.wallet_dir)
  671. except:
  672. pass
  673. def create_wallet_dir():
  674. try:
  675. self.wallet_dir.mkdir(parents=True)
  676. except:
  677. pass
  678. try:
  679. self.wallet_dir.stat()
  680. except:
  681. die(2, f'Unable to create wallet directory ‘{self.wallet_dir}’')
  682. self.gen_key(no_unmount=True)
  683. self.swap.disable()
  684. if sys.platform == 'darwin':
  685. self.macos_ramdisk_setup()
  686. remove_wallet_dir()
  687. create_wallet_dir()
  688. wf = find_file_in_dir(get_wallet_cls('mmgen'), self.cfg.data_dir)
  689. if wf and keypress_confirm(
  690. cfg = self.cfg,
  691. prompt = f'Default wallet ‘{wf}’ found.\nUse default wallet for autosigning?',
  692. default_yes = True):
  693. ss_in = Wallet(Config(), fn=wf)
  694. else:
  695. ss_in = Wallet(self.cfg, in_fmt=self.mn_fmts[self.cfg.mnemonic_fmt or self.dfl_mn_fmt])
  696. ss_out = Wallet(self.cfg, ss=ss_in, passwd_file=str(self.keyfile))
  697. ss_out.write_to_file(desc='autosign wallet', outdir=self.wallet_dir)
  698. if self.cfg.keys_from_file:
  699. self.setup_non_mmgen_keys()
  700. @property
  701. def xmrwallet_cfg(self):
  702. if not hasattr(self, '_xmrwallet_cfg'):
  703. self._xmrwallet_cfg = Config({
  704. '_clone': self.cfg,
  705. 'coin': 'xmr',
  706. 'wallet_rpc_user': 'autosign',
  707. 'wallet_rpc_password': 'autosign password',
  708. 'wallet_rpc_port': 23232 if self.cfg.test_suite_xmr_autosign else None,
  709. 'wallet_dir': str(self.wallet_dir),
  710. 'autosign': True,
  711. 'autosign_mountpoint': str(self.mountpoint),
  712. 'offline': True,
  713. 'compat': False,
  714. 'passwd_file': str(self.keyfile)})
  715. return self._xmrwallet_cfg
  716. def xmr_setup(self):
  717. def create_signing_wallets():
  718. from . import xmrwallet
  719. if len(self.wallet_files) > 1:
  720. ymsg(
  721. 'Warning: more than one wallet file, using the first '
  722. f'({self.wallet_files[0]}) for xmrwallet generation')
  723. m = xmrwallet.op(
  724. 'create_offline',
  725. self.xmrwallet_cfg,
  726. infile = str(self.wallet_files[0]), # MMGen wallet file
  727. wallets = self.cfg.xmrwallets) # XMR wallet idxs
  728. asyncio.run(m.main())
  729. asyncio.run(m.stop_wallet_daemon())
  730. self.clean_old_files()
  731. create_signing_wallets()
  732. def clean_old_files(self):
  733. def do_shred(fn):
  734. nonlocal count
  735. msg_r('.')
  736. shred_file(self.cfg, fn, iterations=15)
  737. count += 1
  738. def clean_dir(s_name):
  739. def clean_files(rawext, sigext):
  740. for f in s.dir.iterdir():
  741. if s.clean_all and (f.name.endswith(f'.{rawext}') or f.name.endswith(f'.{sigext}')):
  742. do_shred(f)
  743. elif f.name.endswith(f'.{sigext}'):
  744. raw = f.parent / (f.name[:-len(sigext)] + rawext)
  745. if raw.is_file():
  746. do_shred(raw)
  747. s = getattr(Signable, s_name)(self)
  748. msg_r(f'Cleaning directory ‘{s.dir}’..')
  749. if s.dir.is_dir():
  750. clean_files(s.rawext, s.sigext)
  751. if hasattr(s, 'subext'):
  752. clean_files(s.rawext, s.subext)
  753. clean_files(s.sigext, s.subext)
  754. msg('done' if s.dir.is_dir() else 'skipped (no dir)')
  755. count = 0
  756. for s_name in self.signables:
  757. clean_dir(s_name)
  758. bmsg(f'{count} file{suf(count)} shredded')
  759. @property
  760. def device_inserted(self):
  761. if self.cfg.no_insert_check:
  762. return True
  763. match sys.platform:
  764. case 'linux':
  765. cp = run(self.linux_blkid_cmd.split(), stdout=PIPE, text=True)
  766. if cp.returncode not in (0, 2):
  767. die(2, f'blkid exited with error code {cp.returncode}')
  768. return self.dev_label in cp.stdout.splitlines()
  769. case 'darwin':
  770. if self.cfg.test_suite_root_pfx:
  771. return self.mountpoint.exists()
  772. else:
  773. return run(
  774. ['diskutil', 'info', self.dev_label],
  775. stdout=DEVNULL, stderr=DEVNULL).returncode == 0
  776. async def main_loop(self):
  777. if not self.cfg.stealth_led:
  778. self.led.set('standby')
  779. threaded = self.cfg.test_suite_autosign_threaded
  780. n = 1 if threaded else 0
  781. prev_status = False
  782. while True:
  783. status = self.device_inserted
  784. if status and not prev_status:
  785. msg('Device insertion detected')
  786. await self.do_sign()
  787. prev_status = status
  788. if not n % 10:
  789. msg_r(f'\r{" "*17}\rWaiting')
  790. await asyncio.sleep(0.2 if threaded else 1)
  791. if not threaded:
  792. msg_r('.')
  793. n += 1
  794. def at_exit(self, exit_val, message=None):
  795. if message:
  796. msg(message)
  797. self.led.stop()
  798. sys.exit(0 if self.cfg.test_suite_autosign_threaded else int(exit_val))
  799. def init_exit_handler(self):
  800. def handler(arg1, arg2):
  801. self.at_exit(1, '\nCleaning up...')
  802. import signal
  803. signal.signal(signal.SIGTERM, handler)
  804. signal.signal(signal.SIGINT, handler)
  805. def init_led(self):
  806. from .led import LEDControl
  807. self.led = LEDControl(
  808. enabled = self.cfg.led,
  809. simulate = self.cfg.test_suite_autosign_led_simulate)
  810. self.led.set('off')
  811. def setup_non_mmgen_keys(self):
  812. from .fileutil import get_lines_from_file, write_data_to_file
  813. from .crypto import Crypto
  814. lines = get_lines_from_file(self.cfg, self.cfg.keys_from_file, desc='keylist data')
  815. write_data_to_file(
  816. self.cfg,
  817. str(self.wallet_dir / self.keylist_fn),
  818. Crypto(self.cfg).mmgen_encrypt(
  819. data = '\n'.join(lines).encode(),
  820. passwd = self.keyfile.read_text()),
  821. desc = 'encrypted keylist data',
  822. binary = True)
  823. if keypress_confirm(self.cfg, 'Securely delete original keylist file?'):
  824. shred_file(self.cfg, self.cfg.keys_from_file)
  825. def init_non_mmgen_keys(self):
  826. if not hasattr(self, 'keylist'):
  827. path = self.wallet_dir / self.keylist_fn
  828. if path.exists():
  829. from .crypto import Crypto
  830. from .fileutil import get_data_from_file
  831. self.keylist = Crypto(self.cfg).mmgen_decrypt(
  832. get_data_from_file(
  833. self.cfg,
  834. path,
  835. desc = 'encrypted keylist data',
  836. binary = True),
  837. passwd = self.keyfile.read_text()).decode().split()
  838. else:
  839. self.keylist = None