mmgen-wallet/mmgen/autosign.py

644 lines
18 KiB
Python
Executable file

#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
# Licensed under the GNU General Public License, Version 3:
# https://www.gnu.org/licenses
# Public project repositories:
# https://github.com/mmgen/mmgen-wallet
# https://gitlab.com/mmgen/mmgen-wallet
"""
autosign: Auto-sign MMGen transactions, message files and XMR wallet output files
"""
import sys,os,asyncio
from pathlib import Path
from subprocess import run,DEVNULL
from .cfg import Config
from .util import msg,msg_r,ymsg,rmsg,gmsg,bmsg,die,suf,fmt,fmt_list,async_run
from .color import yellow,red,orange
from .wallet import Wallet,get_wallet_cls
from .filename import find_file_in_dir
from .ui import keypress_confirm
class AutosignConfig(Config):
_set_ok = ('usr_randchars','_proto','outdir','passwd_file')
class Signable:
signables = ('transaction','message','xmr_transaction','xmr_wallet_outputs_file')
class base:
clean_all = False
multiple_ok = True
def __init__(self,parent):
self.parent = parent
self.cfg = parent.cfg
self.dir = getattr(parent,self.dir_name)
@property
def unsigned(self):
return self._unprocessed( '_unsigned', self.rawext, self.sigext )
@property
def unsubmitted(self):
return self._unprocessed( '_unsubmitted', self.sigext, self.subext )
def _unprocessed(self,attrname,rawext,sigext):
if not hasattr(self,attrname):
dirlist = sorted(self.dir.iterdir())
names = {f.name for f in dirlist}
setattr(
self,
attrname,
tuple(f for f in dirlist
if f.name.endswith('.' + rawext)
and f.name[:-len(rawext)] + sigext not in names) )
return getattr(self,attrname)
def print_bad_list(self,bad_files):
msg('\n{a}\n{b}'.format(
a = red(f'Failed {self.desc}s:'),
b = ' {}\n'.format('\n '.join(self.gen_bad_list(sorted(bad_files,key=lambda f: f.name))))
))
class transaction(base):
desc = 'transaction'
rawext = 'rawtx'
sigext = 'sigtx'
dir_name = 'tx_dir'
fail_msg = 'failed to sign'
async def sign(self,f):
from .tx import UnsignedTX
tx1 = UnsignedTX( cfg=self.cfg, filename=f )
if tx1.proto.sign_mode == 'daemon':
from .rpc import rpc_init
tx1.rpc = await rpc_init( self.cfg, tx1.proto, ignore_wallet=True )
from .tx.sign import txsign
tx2 = await txsign( self.cfg, tx1, self.parent.wallet_files[:], None, None )
if tx2:
tx2.file.write(ask_write=False)
return tx2
else:
return False
def print_summary(self,signables):
if self.cfg.full_summary:
bmsg('\nAutosign summary:\n')
msg_r('\n'.join(tx.info.format(terse=True) for tx in signables))
return
def gen():
for tx in signables:
non_mmgen = [o for o in tx.outputs if not o.mmid]
if non_mmgen:
yield (tx,non_mmgen)
body = list(gen())
if body:
bmsg('\nAutosign summary:')
fs = '{} {} {}'
t_wid,a_wid = 6,44
def gen():
yield fs.format('TX ID ','Non-MMGen outputs'+' '*(a_wid-17),'Amount')
yield fs.format('-'*t_wid, '-'*a_wid, '-'*7)
for tx,non_mmgen in body:
for nm in non_mmgen:
yield fs.format(
tx.txid.fmt( width=t_wid, color=True ) if nm is non_mmgen[0] else ' '*t_wid,
nm.addr.fmt( width=a_wid, color=True ),
nm.amt.hl() + ' ' + yellow(tx.coin))
msg('\n' + '\n'.join(gen()))
else:
msg('\nNo non-MMGen outputs')
def gen_bad_list(self,bad_files):
for f in bad_files:
yield red(f.name)
class xmr_signable(transaction): # mixin class
def need_daemon_restart(self,m,new_idx):
old_idx = self.parent.xmr_cur_wallet_idx
self.parent.xmr_cur_wallet_idx = new_idx
return old_idx != new_idx or m.wd.state != 'ready'
def print_summary(self,signables):
bmsg('\nAutosign summary:')
msg('\n'.join(s.get_info(indent=' ') for s in signables) + self.summary_footer)
class xmr_transaction(xmr_signable):
dir_name = 'xmr_tx_dir'
desc = 'Monero transaction'
subext = 'subtx'
multiple_ok = False
summary_footer = ''
async def sign(self,f):
from .xmrwallet import MoneroMMGenTX,MoneroWalletOps,xmrwallet_uargs
tx1 = MoneroMMGenTX.Completed( self.parent.xmrwallet_cfg, f )
m = MoneroWalletOps.sign(
self.parent.xmrwallet_cfg,
xmrwallet_uargs(
infile = str(self.parent.wallet_files[0]), # MMGen wallet file
wallets = str(tx1.src_wallet_idx),
spec = None ),
)
tx2 = await m.main( f, restart_daemon=self.need_daemon_restart(m,tx1.src_wallet_idx) )
tx2.write(ask_write=False)
return tx2
class xmr_wallet_outputs_file(xmr_signable):
desc = 'Monero wallet outputs file'
rawext = 'raw'
sigext = 'sig'
dir_name = 'xmr_outputs_dir'
clean_all = True
summary_footer = '\n'
async def sign(self,f):
from .xmrwallet import MoneroWalletOps,xmrwallet_uargs
wallet_idx = MoneroWalletOps.wallet.get_idx_from_fn(f)
m = MoneroWalletOps.export_key_images(
self.parent.xmrwallet_cfg,
xmrwallet_uargs(
infile = str(self.parent.wallet_files[0]), # MMGen wallet file
wallets = str(wallet_idx),
spec = None ),
)
obj = await m.main( f, wallet_idx, restart_daemon=self.need_daemon_restart(m,wallet_idx) )
obj.write()
return obj
class message(base):
desc = 'message file'
rawext = 'rawmsg.json'
sigext = 'sigmsg.json'
dir_name = 'msg_dir'
fail_msg = 'failed to sign or signed incompletely'
async def sign(self,f):
from .msg import UnsignedMsg,SignedMsg
m = UnsignedMsg( self.cfg, infile=f )
await m.sign( wallet_files=self.parent.wallet_files[:] )
m = SignedMsg( self.cfg, data=m.__dict__ )
m.write_to_file(
outdir = self.dir.resolve(),
ask_overwrite = False )
if m.data.get('failed_sids'):
die('MsgFileFailedSID',f'Failed Seed IDs: {fmt_list(m.data["failed_sids"],fmt="bare")}')
return m
def print_summary(self,signables):
gmsg('\nSigned message files:')
for message in signables:
gmsg(' ' + message.signed_filename)
def gen_bad_list(self,bad_files):
for f in bad_files:
sigfile = f.parent / ( f.name[:-len(self.rawext)] + self.sigext )
yield orange(sigfile.name) if sigfile.exists() else red(f.name)
class Autosign:
dfl_mountpoint = '/mnt/mmgen_autosign'
dfl_wallet_dir = '/dev/shm/autosign'
old_dfl_mountpoint = '/mnt/tx'
dfl_dev_disk_path = '/dev/disk/by-label/MMGEN_TX'
fake_dev_disk_path = '/tmp/mmgen-test-suite-dev.disk.by-label.MMGEN_TX'
old_dfl_mountpoint_errmsg = f"""
Mountpoint '{old_dfl_mountpoint}' is no longer supported!
Please rename '{old_dfl_mountpoint}' to '{dfl_mountpoint}'
and update your fstab accordingly.
"""
mountpoint_errmsg_fs = """
Mountpoint '{}' does not exist or does not point
to a directory! Please create the mountpoint and add an entry
to your fstab as described in this script’s help text.
"""
mn_fmts = {
'mmgen': 'words',
'bip39': 'bip39',
}
dfl_mn_fmt = 'mmgen'
have_msg_dir = False
def __init__(self,cfg):
self.cfg = cfg
if cfg.mnemonic_fmt:
if cfg.mnemonic_fmt not in self.mn_fmts:
die(1,'{!r}: invalid mnemonic format (must be one of: {})'.format(
cfg.mnemonic_fmt,
fmt_list( self.mn_fmts, fmt='no_spc' ) ))
self.dev_disk_path = Path(
self.fake_dev_disk_path if cfg.test_suite_xmr_autosign else
self.dfl_dev_disk_path )
self.mountpoint = Path(cfg.mountpoint or self.dfl_mountpoint)
self.wallet_dir = Path(cfg.wallet_dir or self.dfl_wallet_dir)
self.tx_dir = self.mountpoint / 'tx'
self.msg_dir = self.mountpoint / 'msg'
self.keyfile = self.mountpoint / 'autosign.key'
cfg.outdir = str(self.tx_dir)
cfg.passwd_file = str(self.keyfile)
if any(k in cfg._uopts for k in ('help','longhelp')):
return
if 'coin' in cfg._uopts:
die(1,'--coin option not supported with this command. Use --coins instead')
self.coins = cfg.coins.upper().split(',') if cfg.coins else []
if cfg._args and cfg._args[0] == 'clean':
return
if cfg.xmrwallets and not 'XMR' in self.coins:
self.coins.append('XMR')
if not self.coins:
ymsg('Warning: no coins specified, defaulting to BTC')
self.coins = ['BTC']
if 'XMR' in self.coins:
self.xmr_dir = self.mountpoint / 'xmr'
self.xmr_tx_dir = self.mountpoint / 'xmr' / 'tx'
self.xmr_outputs_dir = self.mountpoint / 'xmr' / 'outputs'
self.xmr_cur_wallet_idx = None
async def check_daemons_running(self):
from .protocol import init_proto
for coin in self.coins:
proto = init_proto( self.cfg, coin, testnet=self.cfg.network=='testnet', need_amt=True )
if proto.sign_mode == 'daemon':
self.cfg._util.vmsg(f'Checking {coin} daemon')
from .rpc import rpc_init
from .exception import SocketError
try:
await rpc_init( self.cfg, proto, ignore_wallet=True )
except SocketError as e:
from .daemon import CoinDaemon
d = CoinDaemon( self.cfg, proto=proto, test_suite=self.cfg.test_suite )
die(2,
f'\n{e}\nIs the {d.coind_name} daemon ({d.exec_fn}) running '
+ 'and listening on the correct port?' )
@property
def wallet_files(self):
if not hasattr(self,'_wallet_files'):
try:
dirlist = self.wallet_dir.iterdir()
except:
die(1,f"Cannot open wallet directory '{self.wallet_dir}'. Did you run ‘mmgen-autosign setup’?")
self._wallet_files = [f for f in dirlist if f.suffix == '.mmdat']
if not self._wallet_files:
die(1,'No wallet files present!')
return self._wallet_files
def do_mount(self,no_xmr_chk=False):
from stat import S_ISDIR,S_IWUSR,S_IRUSR
def check_dir(cdir):
try:
ds = cdir.stat()
assert S_ISDIR(ds.st_mode), f"'{cdir}' is not a directory!"
assert ds.st_mode & S_IWUSR|S_IRUSR == S_IWUSR|S_IRUSR, f"'{cdir}' is not read/write for this user!"
except:
die(1,f"'{cdir}' missing or not read/writable by user!")
if not self.mountpoint.is_dir():
def do_die(m):
die(1,'\n' + yellow(fmt(m.strip(),indent=' ')))
if Path(self.old_dfl_mountpoint).is_dir():
do_die(self.old_dfl_mountpoint_errmsg)
else:
do_die(self.mountpoint_errmsg_fs.format(self.mountpoint))
if not self.mountpoint.is_mount():
if run( ['mount',self.mountpoint], stderr=DEVNULL, stdout=DEVNULL ).returncode == 0:
msg(f"Mounting '{self.mountpoint}'")
elif not self.cfg.test_suite:
die(1,f"Unable to mount device at '{self.mountpoint}'")
self.have_msg_dir = self.msg_dir.is_dir()
check_dir(self.tx_dir)
if self.have_msg_dir:
check_dir(self.msg_dir)
if 'XMR' in self.coins and not no_xmr_chk:
check_dir(self.xmr_tx_dir)
def do_umount(self):
if self.mountpoint.is_mount():
run( ['sync'], check=True )
msg(f"Unmounting '{self.mountpoint}'")
run( ['umount',self.mountpoint], check=True )
bmsg('It is now safe to extract the removable device')
def decrypt_wallets(self):
msg(f"Unlocking wallet{suf(self.wallet_files)} with key from '{self.cfg.passwd_file}'")
fails = 0
for wf in self.wallet_files:
try:
Wallet( self.cfg, wf, ignore_in_fmt=True )
except SystemExit as e:
if e.code != 0:
fails += 1
return not fails
async def sign_all(self,target_name):
target = getattr(Signable,target_name)(self)
if target.unsigned:
good = []
bad = []
if len(target.unsigned) > 1 and not target.multiple_ok:
ymsg(f'Autosign error: only one unsigned {target.desc} transaction allowed at a time!')
target.print_bad_list(target.unsigned)
return False
for f in target.unsigned:
ret = None
try:
ret = await target.sign(f)
except Exception as e:
ymsg(f"An error occurred with {target.desc} '{f.name}':\n {type(e).__name__}: {e!s}")
except:
ymsg(f"An error occurred with {target.desc} '{f.name}'")
good.append(ret) if ret else bad.append(f)
self.cfg._util.qmsg('')
await asyncio.sleep(0.3)
msg(f'{len(good)} {target.desc}{suf(good)} signed')
if bad:
rmsg(f'{len(bad)} {target.desc}{suf(bad)} {target.fail_msg}')
if good and not self.cfg.no_summary:
target.print_summary(good)
if bad:
target.print_bad_list(bad)
return not bad
else:
msg(f'No unsigned {target.desc}s')
await asyncio.sleep(0.5)
return True
async def do_sign(self):
if not self.cfg.stealth_led:
self.led.set('busy')
self.do_mount()
key_ok = self.decrypt_wallets()
if key_ok:
if self.cfg.stealth_led:
self.led.set('busy')
ret1 = await self.sign_all('transaction')
ret2 = await self.sign_all('message') if self.have_msg_dir else True
# import XMR wallet outputs BEFORE signing transactions:
ret3 = await self.sign_all('xmr_wallet_outputs_file') if 'XMR' in self.coins else True
ret4 = await self.sign_all('xmr_transaction') if 'XMR' in self.coins else True
ret = ret1 and ret2 and ret3 and ret4
self.do_umount()
self.led.set(('standby','off','error')[(not ret)*2 or bool(self.cfg.stealth_led)])
return ret
else:
msg('Password is incorrect!')
self.do_umount()
if not self.cfg.stealth_led:
self.led.set('error')
return False
def wipe_existing_key(self):
try:
self.keyfile.stat()
except:
pass
else:
from .fileutil import shred_file
msg(f"\nShredding existing key '{self.keyfile}'")
shred_file( self.keyfile, verbose=self.cfg.verbose )
def create_key(self):
desc = f"key file '{self.keyfile}'"
msg('Creating ' + desc)
try:
self.keyfile.write_text( os.urandom(32).hex() )
self.keyfile.chmod(0o400)
except:
die(2,'Unable to write ' + desc)
msg('Wrote ' + desc)
def gen_key(self,no_unmount=False):
if not self.get_insert_status():
die(1,'Removable device not present!')
self.do_mount(no_xmr_chk=True)
self.wipe_existing_key()
self.create_key()
if not no_unmount:
self.do_umount()
def setup(self):
def remove_wallet_dir():
msg(f"Deleting '{self.wallet_dir}'")
import shutil
try:
shutil.rmtree(self.wallet_dir)
except:
pass
def create_wallet_dir():
try:
self.wallet_dir.mkdir(parents=True)
except:
pass
try:
self.wallet_dir.stat()
except:
die(2,f"Unable to create wallet directory '{self.wallet_dir}'")
remove_wallet_dir()
create_wallet_dir()
self.gen_key(no_unmount=True)
wf = find_file_in_dir( get_wallet_cls('mmgen'), self.cfg.data_dir )
if wf and keypress_confirm(
cfg = self.cfg,
prompt = f"Default wallet '{wf}' found.\nUse default wallet for autosigning?",
default_yes = True ):
ss_in = Wallet( Config(), wf )
else:
ss_in = Wallet( self.cfg, in_fmt=self.mn_fmts[self.cfg.mnemonic_fmt or self.dfl_mn_fmt] )
ss_out = Wallet( self.cfg, ss=ss_in )
ss_out.write_to_file( desc='autosign wallet', outdir=self.wallet_dir )
@property
def xmrwallet_cfg(self):
if not hasattr(self,'_xmrwallet_cfg'):
self._xmrwallet_cfg = Config({
'_clone': self.cfg,
'coin': 'xmr',
'wallet_rpc_user': 'autosign',
'wallet_rpc_password': 'autosign password',
'wallet_rpc_port': 23232 if self.cfg.test_suite_xmr_autosign else None,
'wallet_dir': str(self.wallet_dir),
'autosign': True,
'autosign_mountpoint': str(self.mountpoint),
'outdir': str(self.xmr_dir), # required by vkal.write()
})
return self._xmrwallet_cfg
def xmr_setup(self):
def create_signing_wallets():
from .xmrwallet import MoneroWalletOps,xmrwallet_uargs
if len(self.wallet_files) > 1:
ymsg(f'Warning: more than one wallet file, using the first ({self.wallet_files[0]}) for xmrwallet generation')
m = MoneroWalletOps.create_offline(
self.xmrwallet_cfg,
xmrwallet_uargs(
infile = str(self.wallet_files[0]), # MMGen wallet file
wallets = self.cfg.xmrwallets, # XMR wallet idxs
spec = None ),
)
async_run(m.main())
async_run(m.stop_wallet_daemon())
import shutil
try:
shutil.rmtree(self.xmr_outputs_dir)
except:
pass
self.xmr_outputs_dir.mkdir(parents=True)
self.xmr_tx_dir.mkdir(exist_ok=True)
self.clean_old_files()
create_signing_wallets()
def clean_old_files(self):
def do_shred(f):
nonlocal count
msg_r('.')
from .fileutil import shred_file
shred_file( f, verbose=self.cfg.verbose )
count += 1
def clean_dir(s_name):
def clean_files(rawext,sigext):
for f in s.dir.iterdir():
if s.clean_all and (f.name.endswith(f'.{rawext}') or f.name.endswith(f'.{sigext}')):
do_shred(f)
elif f.name.endswith(f'.{sigext}'):
raw = f.parent / ( f.name[:-len(sigext)] + rawext )
if raw.is_file():
do_shred(raw)
s = getattr(Signable,s_name)(asi)
msg_r(f"Cleaning directory '{s.dir}'..")
if s.dir.is_dir():
clean_files( s.rawext, s.sigext )
if hasattr(s,'subext'):
clean_files( s.rawext, s.subext )
clean_files( s.sigext, s.subext )
msg('done' if s.dir.is_dir() else 'skipped (no dir)')
asi = get_autosign_obj( self.cfg, 'btc,xmr' )
count = 0
for s_name in Signable.signables:
clean_dir(s_name)
bmsg(f'{count} file{suf(count)} shredded')
def get_insert_status(self):
if self.cfg.no_insert_check:
return True
try:
self.dev_disk_path.stat()
except:
return False
else:
return True
async def do_loop(self):
if not self.cfg.stealth_led:
self.led.set('standby')
testing_xmr = self.cfg.test_suite_xmr_autosign
if testing_xmr:
msg('Waiting for fake device insertion')
n = 1 if testing_xmr else 0
prev_status = False
while True:
status = self.get_insert_status()
if status and not prev_status:
msg('Device insertion detected')
await self.do_sign()
if testing_xmr:
if self.dev_disk_path.exists():
self.dev_disk_path.unlink()
prev_status = status
if not n % 10:
msg_r(f"\r{' '*17}\rWaiting")
await asyncio.sleep(1)
if not testing_xmr:
msg_r('.')
n += 1
def at_exit(self,exit_val,message=None):
if message:
msg(message)
self.led.stop()
sys.exit(0 if self.cfg.test_suite_xmr_autosign else int(exit_val))
def init_exit_handler(self):
def handler(arg1,arg2):
self.at_exit(1,'\nCleaning up...')
import signal
signal.signal( signal.SIGTERM, handler )
signal.signal( signal.SIGINT, handler )
def init_led(self):
from .led import LEDControl
self.led = LEDControl(
enabled = self.cfg.led,
simulate = self.cfg.test_suite_autosign_led_simulate )
self.led.set('off')
def get_autosign_obj(cfg,coins=None):
return Autosign(
AutosignConfig({
'mountpoint': cfg.autosign_mountpoint or cfg.mountpoint,
'test_suite': cfg.test_suite,
'coins': coins if isinstance(coins,str) else ','.join(coins) if coins else 'btc',
})
)