#!/usr/bin/python
"""A utility to sign all UIDs on a list of PGP keys and PGP/Mime encrypt-email
them to the respective emails."""

# vim:shiftwidth=2:tabstop=2:expandtab:textwidth=80:softtabstop=2:ai:

#
# Copyright (c) 2008 - present Phil Dibowitz (phil@ipom.com)
#
#   This program is free software; you can redistribute it and/or
#   modify it under the terms of the GNU General Public License as
#   published by the Free Software Foundation, version 2.
#
# Note that we only import pexpect if -i is specified. In order to do this
# in a relatively clean way (if you just import it it will only be local), we
# use a trick that pylint will complain about. I'm quite alright with that.
#
# TODO:
#   - Offer ability to "pick up where we left off"
#

from libpius import signer as psigner
from libpius import mailer as pmailer
from libpius.state import SignState
from libpius.constants import *
from libpius.exceptions import *
from libpius import util
from copy import copy
from optparse import OptionParser, Option, OptionValueError
import os
import re
import sys

def print_default_email(no_mime):
  '''Print the default email that is sent out.'''
  interpolation_dict = {'keyid': '<keyid>', 'signer': '<signer>',
                        'email': '<email>'}
  print 'DEFAULT EMAIL TEXT:\n'
  if not no_mime:
    print DEFAULT_MIME_EMAIL_TEXT % interpolation_dict
  else:
    print DEFAULT_NON_MIME_EMAIL_TEXT % interpolation_dict

#
# Stupid fucking optparse will assume "-m -e" means "-e is the email address
# being passed to -m"... instead of "oh, -e is an option, -m is missing it's
# required argument. This is an ugly hack around that.
#
def check_not_another_opt(option, opt, value):
  '''Ensure argument to an option isn't another option.'''
  match = re.search('^\-', value)
  if match:
    raise OptionValueError('Option %s: Value %s looks like another option'
                           ' instead of the required argument' % (opt, value))
  return value

def check_email(option, opt, value):
  '''Ensure argument seems like an email address.'''
  match = re.match('.+@.+\..+', value)
  if not match:
    raise OptionValueError('Option %s: Value %s does not appear like a well'
                           ' formed email address' % (opt, value))
  return value

def check_keyid(option, opt, value):
  '''Ensure argument seems like a keyid.'''
  match = re.match('[0-9a-fA-Fx]', value)
  if not match:
    raise OptionValueError('Option %s: Value %s does not appear to be a KeyID'
                           % (opt, value))
  return value


class MyOption(Option):
  '''Our own option class.'''
  TYPES = Option.TYPES + ('not_another_opt', 'email', 'keyid')
  TYPE_CHECKER = copy(Option.TYPE_CHECKER)
  TYPE_CHECKER.update({'not_another_opt': check_not_another_opt,
                       'email': check_email,
                       'keyid': check_keyid})
# END Stupid python optparse hack.


def check_options(parser, options, args):
  '''Given the parsed options, sanity check them.'''

  if options.debug == True:
    print 'Setting debug'
    util.DEBUG_ON = True

  if not os.path.exists(options.gpg_path):
    parser.error('GnuPG binary not found at %s.' % options.gpg_path)

  if not options.signer:
    parser.error('You must specify a keyid to sign with.')

  if options.keyring:
    options.keyring = os.path.expanduser(options.keyring)
    if not os.path.exists(options.keyring):
      parser.error('Keyring %s doesn\'t exist' % options.keyring)

  if not options.all_keys:
    if not args:
      parser.error('Keyid (or -A) required')
  elif not options.keyring:
    parser.error('The -A options requires the -r option')

  if options.mail and options.mail_no_pgp_mime and not options.encrypt_outfiles:
    print 'NOTE: -O and -m are present, turning on -e'
    options.encrypt_outfiles = True

  if options.mail_user and not options.mail_tls:
    print 'NOTE: -u is present, turning off -S.'
    options.mail_tls = True

  if options.mail_text and not options.mail:
    parser.error('ERROR: -M requires -m')

  for mydir in (options.tmp_dir, options.out_dir):
    if os.path.exists(mydir) and not os.path.isdir(mydir):
      parser.error('%s exists but isn\'t a directory. It must not exist or be\n'
                   'a directory.' % mydir)
    if not os.path.exists(mydir):
      os.mkdir(mydir, 0700)

def parse_dotfile(parser):
  tmp_file = PIUS_HOME + 'rc'
  sep = re.compile(r'(?:\s*=\s*|\s*:\s*\s+)')

  # Handle conversion of old rc file
  if os.path.isfile(PIUS_HOME):
    print 'Converting ~/.pius to ~/.pius/piusrc'
    # temporarily rename ~/.pius to ~/.piusrc
    os.rename(PIUS_HOME, tmp_file)
    os.mkdir(PIUS_HOME, 0755)
    os.rename(tmp_file, PIUS_RC)
  # Handle partial conversion
  elif os.path.isfile(tmp_file) and not os.path.islink(tmp_file):
    if not os.path.isdir(PIUS_HOME):
      os.mkdir(PIUS_HOME, 0755)
    if not os.path.isfile(PIUS_RC):
      os.rename(tmp_file, PIUS_RC)
    else:
      print 'WARNING: Both %s and %s exist... ignoring %s' % (PIUS_RC, tmp_file)

  # if we have a config file, parse it
  opts = []
  if os.path.isfile(PIUS_RC):
    fp = open(PIUS_RC, 'r')
    for line in fp:
      if line.startswith('#'):
        continue
      parts = sep.split(line.strip())
      if not parts[0].startswith('--'):
        parts[0] = '--%s' % parts[0]
      if parser.has_option(parts[0]):
        opts.extend(parts)
      else:
        print('WARNING: Invalid line "%s" in %s, ignoring.' %
              (line.strip(), PIUS_RC))
    fp.close()

  return opts

def warn_if_short_keyids(ids):
  for keyid in ids:
    if len(keyid) == 8:
      print('WARNING: You passed in short keyids. Short keyids are forgable'
            ' and should be avoided.')
      ans = raw_input('Type "I understand" to continue: ')
      if ans == 'I understand':
        return
      else:
        print 'ERROR: Danger not acknowledged, exiting.'
        sys.exit(1)

def main():
  """Main."""
  usage = ('%prog [options] -s <signer_keyid> <keyid> [<keyid> ...]\n'
           '       %prog [options] -A -r <keyring_path> -s <signer_keyid>')
  parser = OptionParser(usage=usage, version='%%prog %s' % VERSION,
                        option_class=MyOption)
  parser.set_defaults(mode=MODE_CACHE_PASSPHRASE,
                      gpg_path=DEFAULT_GPG_PATH,
                      out_dir=DEFAULT_OUT_DIR,
                      tmp_dir=DEFAULT_TMP_DIR,
                      keyring=DEFAULT_KEYRING,
                      sort_keyring=True,
                      mail_host=DEFAULT_MAIL_HOST,
                      mail_port=DEFAULT_MAIL_PORT,
                      mail_tls=True)
  parser.add_option('-a', '--use-agent', action='store_const', const=MODE_AGENT,
                    dest='mode',
                    help='Use pgp-agent instead of letting gpg prompt the'
                         ' user for every UID. [default: false]')
  parser.add_option('-A', '--all-keys', action='store_true', dest='all_keys',
                    help='Sign all keys on the keyring. Requires -r.')
  parser.add_option('-b', '--gpg-path', dest='gpg_path', metavar='PATH',
                    nargs=1, type="not_another_opt",
                    help='Path to gpg binary. [default: %default]')
  parser.add_option('-e', '--encrypt-outfiles', action='store_true',
                    dest='encrypt_outfiles',
                    help='Encrypt output files with respective keys.')
  parser.add_option('-d', '--debug', action='store_true', dest='debug',
                    help='Enable debugging output.')
  parser.add_option('-H', '--mail-host', dest='mail_host', metavar='HOSTNAME',
                    nargs=1, type='not_another_opt',
                    help='Hostname of SMTP server. [default: %default]')
  parser.add_option('-i', '--interactive', action='store_const',
                    const=MODE_INTERACTIVE, dest='mode', help='Use the pexpect'
                      ' module for signing and drop to the gpg shell for'
                      ' entering the passphrase. [default: false]')
  parser.add_option('-I', '--import', action='store_true',
                    dest='import_keyring',
                    help='Also import the unsigned keys from the keyring'
                         ' into the default keyring. Ignored if -r is not'
                         ' specified, or if it\'s the same as the default'
                         ' keyring.')
  parser.add_option('-m', '--mail', dest='mail', metavar='EMAIL', nargs=1,
                    type='email',
                    help='Email the encrypted, signed keys to the'
                          ' respective email addresses. EMAIL is the address'
                          ' to send from. See also -H and -P.')
  parser.add_option('-M', '--mail-text', dest='mail_text', metavar='FILE',
                    nargs=1, type='not_another_opt',
                    help='Use the text in FILE as the body of email when'
                          ' sending out emails instead of the default text.'
                          ' To see the default text use'
                          ' --print-default-email. Requires -m.')
  parser.add_option('-N', '--no-sort-keyring', dest='sort_keyring',
                    action='store_false',
                    help='Do not sort the keyring by name.')
  parser.add_option('-n', '--override-email', dest='mail_override',
                    metavar='EMAIL', nargs=1, type='email',
                    help='Rather than send to the user, send to this address.'
                         ' Mostly useful for debugging.')
  parser.add_option('-o', '--out-dir', dest='out_dir', metavar='OUTDIR',
                    nargs=1, type='not_another_opt',
                    help='Directory to put signed keys in. [default: %default]')
  parser.add_option('-O', '--no-pgp-mime', action='store_true',
                    dest='mail_no_pgp_mime',
                    help='Do not use PGP/Mime when sending email.')
  parser.add_option('-p', '--cache-passphrase', action='store_const',
                    const=MODE_CACHE_PASSPHRASE, dest='mode',
                    help='Cache private key passphrase in memory and provide'
                         ' it to gpg instead of letting gpg prompt the user'
                         ' for every UID. [default: true]')
  parser.add_option('-P', '--mail-port', dest='mail_port', metavar='PORT',
                    nargs=1, type='int',
                    help='Port of SMTP server. [default: %default]')
  parser.add_option('-r', '--keyring', dest='keyring', metavar='KEYRING',
                    nargs=1, type='not_another_opt',
                    help='The keyring to use. Be sure to specify full or'
                         ' relative path. Just a filename will cause GPG to'
                         ' assume relative to ~/.gnupg. [default: %default]')
  parser.add_option('-s', '--signer', dest='signer', nargs=1,
                    type='keyid',
                    help='The keyid to sign with (required).')
  parser.add_option('-S', '--no-mail-tls', action='store_false',
                    dest='mail_tls',
                    help='Do not use STARTTLS when talking to the SMTP server.')
  parser.add_option('-t', '--tmp-dir', dest='tmp_dir', nargs=1,
                    type='not_another_opt',
                    help='Directory to put temporary stuff in. [default:'
                         ' %default]')
  parser.add_option('-T', '--print-default-email', dest='print_default_email',
                    action='store_true', help='Print the default email.')
  parser.add_option('-u', '--mail-user', dest='mail_user', metavar='USER',
                    type='not_another_opt', nargs=1,
                    help='Authenticate to the SMTP server, and use username'
                         ' USER. You will be prompted for the password.')
  parser.add_option('-U', '--policy-url', dest='policy_url',
                    help='Policy URL to include in each signature')
  parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
                    help='Be more verbose.')

  # Check for extra options in the ~/.pius file
  all_opts = []
  all_opts = parse_dotfile(parser)
  # Note that by putting this at the end we allow the command line to override
  # options specified in the config file, BUT if any options conflict, the first
  # wins, so the config file wins. Meh.
  all_opts.extend(sys.argv[1:])
  (options, args) = parser.parse_args(all_opts)

  print 'Welcome to PIUS, the PGP Individual UID Signer.\n'

  # The easy thing first...
  if options.print_default_email:
    print_default_email(options.mail_no_pgp_mime)
    sys.exit(0)

  # Check input to make sure users want sane things
  check_options(parser, options, args)

  # Check to see if the user wants to send email if they didn't specify
  if not options.mail:
    ans = raw_input('Would you like to automatically send the signed UIDs to'
                    ' their owners using\nPGP/Mime encryption as you sign each'
                    ' one? ')
    if ans in ('y', 'Y', 'yes', 'YES', 'Yes'):
      ans = raw_input('What email address should we send from? ')
      check_email(parser, '-m', ans)
      options.mail = ans
      print

  if options.mail:
    mailer = pmailer.PiusMailer(
      options.mail,
      options.mail_host,
      options.mail_port,
      options.mail_user,
      options.mail_tls,
      options.mail_no_pgp_mime,
      options.mail_override,
      options.mail_text,
      options.tmp_dir
    )
  else:
    mailer = None

  signer = psigner.PiusSigner(
    options.signer,
    options.mode,
    options.keyring,
    options.gpg_path,
    options.tmp_dir,
    options.out_dir,
    options.encrypt_outfiles,
    options.mail,
    mailer,
    options.verbose,
    options.sort_keyring,
    options.policy_url,
    options.mail_host
  )

  if options.all_keys:
    key_list = signer.get_all_keyids()
    if len(key_list) == 0:
      print "ERROR: Failed to find keys on this keyring\n"
      sys.exit(1)
    if args:
      key_list.extend(args)
  else:
    warn_if_short_keyids(args)
    key_list = args

  if options.mode == MODE_CACHE_PASSPHRASE:
    print 'NOTE: See the README about security implications.'
    while True:
      signer.get_passphrase()
      if not signer.verify_passphrase():
        print 'Sorry, cannot unlock the key with that passphrase, try again.'
      else:
        break

  if options.mail_user:
    while True:
      mailer.get_pass()
      try:
        if not mailer.verify_pass():
          print ('Sorry, cannot authenticate to %s as %s with that passwword,'
                 ' try again.' % (options.mail_host, options.mail_user))
        else:
          break
      except MailSendError, msg:
        print ('There was a problem talking to the mail server (%s): %s'
               % (options.mail_host, msg))
        sys.exit(1)

  # The actual signing
  signed_keys = {}
  for key in key_list:
    retval = signer.check_fingerprint(key)
    if retval == False:
      continue
    print 'Signing all UIDs on key %s' % key
    if signer.sign_all_uids(key, retval):
      signed_keys[key] = True
    print ''

  # If the user asked, import the keys
  if options.import_keyring:
    if ((not options.keyring) or (options.keyring == DEFAULT_KEYRING)):
      print ('WARNING: Ignoring -i: Either -r wasn\'t specified, or it was'
             ' the same as the default keyring.')
    else:
      signer.import_unsigned_keys()

  signer.cleanup()
  SignState.store_signed_keys(dict((x, 'SIGNED') for x in signed_keys))

if __name__ == '__main__':
  main()
