"""Module to manipulate certificates in an OS X Keychain, and related functions.

Importing this will run LoginKeychain(), which tries to determine the importing
user's login keychain and sets the global variable login_keychain accordingly.
"""

import datetime
import logging
import operator
import os
import re
import shutil
import tempfile
from . import gmacpyutil
from . import getauth


CMD_OPENSSL = '/usr/bin/openssl'
CMD_SECURITY = '/usr/bin/security'
PEM_HEADER = '-----BEGIN CERTIFICATE-----'
PEM_FOOTER = '-----END CERTIFICATE-----'
OPENSSL_DATETIME_FORMAT = '%b %d %H:%M:%S %Y %Z'
SYSTEM_KEYCHAIN = '/Library/Keychains/System.keychain'
login_keychain = None


class Error(Exception):
  """Base error class."""


class CertError(Error):
  """Module specific exception class."""


class KeychainError(Error):
  """Module specific exception class."""


class Certificate(object):
  """Basic certificate object.

  The Certificate constructor accepts a string of PEM-encoded data. The object
  will populate with attributes decoded from the PEM.

  Certificate objects will have these read-only attributes:
  certhash        hash of the certificate's subject name
  subject         string representation of the certificate's full subject
  subject_cn      the CN of the subject
  serial          string representation of the certificate's serial number
  issuer          string representation of the certificate's full issuer
  issuer_cn       the CN of the issuer
  startdate       list, the notBefore date and a datettime object of the same
  enddate         list, the notAfter date and a datettime object of the same
  fingerprint     digest of the DER encoded version of the whole certificate
  osx_fingerprint fingerprint with the ':' removed

  .. and may have this read-only attribute:
  email           email addresses

  Attributes:
    pem: str, PEM-encoded data
  """

  def __init__(self, pem):
    self.pem = pem
    self._ParsePEMCertificate(pem)

  def get(self, key):  # pylint: disable=g-bad-name
    return self.__dict__.get(key, None)


  def _ParsePEMCertificate(self, pem):
    """Reads data from PEM encoded certificate.

    This method take a PEM-encoded certificate and parses it with openssl x509.
    It adds attributes to the object from the parsed output. We feed the PEM
    data though openssl x509 once, with a given order of requested attributes,
    and then parse the output to retrieve the attributes.

    For issuer and subject, the output is "<attribute>= <value>", and we set the
    attribute to <value>.
    For issuer_cn and subject_cn, we get "<attribute>= /X=ZZ/CN=VALUE" and
    set <attribute>_cn VALUE. If the CN is not where we expect, don't set the
    attribute.
    For fingerprint, the output is "SHA1 Fingerprint=fi:ng:er:pr:in:tt", and we
    set fingerprint to "fi:ng:er:pr:in:tt" and osx_fingerprint to
    "fingerprintt".
    For startdate and enddate, we get "notAfter=Apr 29 18:09:17 2036 GMT" and
    make a list. Index 0 has the full date string, index 1 is a datetime object
    parsed from the string. If the string is in an unknown format, index 1 will
    be None.
    For hash, no parsing is needed as the output is just the hash.
    For email, we join up all the remaining lines of output and add them.

    Args:
      pem: str, PEM-encoded data

    Raises:
      CertError: unable to get certificate data
    """
    command = [CMD_OPENSSL, 'x509', '-sha1', '-nameopt', 'compat', '-noout']
    attrs = ['hash', 'subject', 'issuer', 'startdate', 'enddate', 'fingerprint',
             'serial', 'email']

    for attr in attrs:
      command.extend(['-%s' % attr])
    (stdout, stderr, returncode) = gmacpyutil.RunProcess(command, pem)
    if returncode:
      raise CertError('Unable to retrieve data for certificates: %s' % stderr)
    output = stdout.splitlines()
    for index, attr in enumerate(attrs):
      if attr in ('issuer', 'subject'):
        name = output[index].split(' ', 1)[1]
        self.__dict__[attr] = name
        try:
          self.__dict__[''.join([attr, '_cn'])] = (
              name.split('/')[-1].split('=')[1])
        except IndexError:
          pass
      elif attr == 'fingerprint':
        self.__dict__['fingerprint'] = output[index].split('=', 1)[1]
        self.__dict__['osx_fingerprint'] = (
            re.sub(':', '', output[index].split('=', 1)[1]))
      elif attr in ('enddate', 'startdate'):
        datestring = output[index].split('=')[1]
        try:
          dateobject = datetime.datetime.strptime(datestring,
                                                  OPENSSL_DATETIME_FORMAT)
        except ValueError:
          dateobject = None
        self.__dict__[attr] = [datestring, dateobject]
      elif attr == 'hash':
        self.__dict__['certhash'] = output[index]
      elif attr == 'serial':
        self.__dict__[attr] = output[index].split('=')[1]
      elif attr == 'email':
        self.__dict__[attr] = ''.join(output[index:])


def LoginKeychain():
  """Gets the current user's login keychain and updates login_keychain."""
  global login_keychain  # pylint: disable=global-statement
  if os.uname()[0] == 'Linux':
    return
  if os.getuid() == 0:
    logging.debug('Root has no access to login keychain.')
    return
  stdout, stderr, rc = gmacpyutil.RunProcess([CMD_SECURITY, 'login-keychain'])
  if rc == 0:
    login_keychain = stdout.strip(' \n"')
  else:
    logging.error('Unable to determine login keychain: %s', stderr)
    login_keychain = None


def _GetCertificates(keychain=None):
  """Gets all certificates in a given keychain.

  On a newly-created keychain, searching for all certs gives a
  CSSMERR_DL_INVALID_RECORDTYPE error and sets the returncode to 9. Just
  assume there are no certs in this case.

  Args:
    keychain: str, keychain to look in

  Yields:
    Certificate objects

  Raises:
    CertError: could not search for certficates
    StopIteration: no more matching certs
  """
  cmd = [CMD_SECURITY, 'find-certificate', '-a', '-p']

  if keychain is not None:
    cmd.extend([keychain])

  (stdout, stderr, returncode) = gmacpyutil.RunProcess(cmd)
  if returncode == 0:
    allcerts = stdout.split(PEM_FOOTER)
    allcerts.pop()
    for cert in allcerts:
      pem = '%s\n%s\n%s' % (PEM_HEADER, '\n'.join(cert.split()[2:]), PEM_FOOTER)
      try:
        yield Certificate(pem)
      except CertError, e:
        logging.info('Encountered an unparseable certificate, continuing.')
        logging.debug(str(e))
        continue
  elif returncode == 9:
    raise StopIteration
  else:
    raise CertError('Unable to get all certificates. Exit code: %s, '
                    'Output: %s' % (returncode, stderr))


def DeleteCert(osx_fingerprint, keychain=None, gui=False,
               password=None):
  """Deletes a certificate by SHA1 hash.

  Args:
    osx_fingerprint: str, SHA-1 hash (uppercase, no colons)
    keychain: str, keychain to delete from; if unset the default keychain is
              used (normally the login keychain)
    gui: True if running in a gui context
    password: The user's password if already known.

  Raises:
    CertError: unable to delete certificate
  """
  sudo, sudo_pass = _GetSudoContext(keychain,
                                    gui=gui,
                                    password=password)
  cmd = [CMD_SECURITY, 'delete-certificate', '-Z', osx_fingerprint]
  if keychain:
    cmd.append(keychain)
  (unused_stdout, stderr, returncode) = (
      gmacpyutil.RunProcess(cmd, sudo=sudo, sudo_password=sudo_pass))

  if returncode:
    raise CertError('Unable to delete certificate: %s' % stderr)


def FindCertificates(subject=None, subject_cn=None, issuer=None, issuer_cn=None,
                     startdate=None, enddate=None, certhash=None,
                     fingerprint=None, email=None, keychain=None):
                     # pylint: disable=unused-argument
  """Finds certificates by attribute.

  Multiple attributes are ANDed together.

  To do this, we get a list of all matching certs with a list comprehension,
  then run a reduce multiplying all results. Since bools multiply as ints, a
  False means multiply by 0 so any False match makes the whole thing False.

  Args:
    subject: str, find certificate by full subject
    subject_cn: str, find certificate by subject CN
    issuer: str, find certificate by issuer
    issuer_cn: str, find certificate by issuer CN
    startdate: str, find certificate by notBefore date
    enddate: str, find certificate by notAfter date
    certhash: str, find certificate by SHA-1 hash of suject name
    fingerprint: str, find certificate by fingerprint
    email: str, find certificates by email address
    keychain: str, which keychain to look in
  Returns:
    List of matching Certificate objects
  Raises:
    CertError: could not search for certificates
  """

  attrs = vars().keys()[:]
  attrs.remove('keychain')
  allcerts = _GetCertificates(keychain=keychain)
  matchedcerts = []

  for cert in allcerts:
    if reduce(operator.mul,  # pylint: disable=bad-builtin
              [(cert.get(k) == eval(k))  # pylint: disable=eval-used
               for k in attrs
               if eval(k)],  # pylint: disable=eval-used
              True):
      matchedcerts.append(cert)
  return matchedcerts


def CertificateExpired(cert, expires=0):
  """Checks a given certificate for expiry.

  Args:
    cert: Certificate object
    expires: int, the number of seconds to check for expiry. 0 means now
  Returns:
    boolean, whether the certificate will expire in expires seconds
  Raises:
    CertError: cert is a mandatory argument
    CertError: cert is not a PEM encoded x509 cert
  """
  expiry = datetime.datetime.today() + datetime.timedelta(seconds=expires)
  # enddate is a list of [str, (datetime|None)], we want the datetime object
  cert_end = cert.enddate[1]
  if cert_end:
    return expiry > cert_end
  else:
    raise CertError('Certificate has a malformed enddate.')




def VerifyIdentityPreference(subject_cn, service):
  """Verify a TLS identity preference exists for a given cert.

  Args:
    subject_cn: str, subject CN of cert to verify matches preference
    service: str, Service for which identity should be verified

  Returns:
    Bool: true if identity exists and matches subject_cn
  """
  cmd = [CMD_SECURITY, 'get-identity-preference', '-s', service, '-c']
  (stdout, unused_stderr, rc) = gmacpyutil.RunProcess(cmd)

  if rc:
    return False

  search_string = '"labl"<blob>="%s"' % subject_cn
  return search_string in stdout


def ClearIdentityPreferences(sudo_password=None):
  """Deletes existing TLS identity preferences.

  There's no way to list all identity preferences without knowing the full name
  so it's necessary to dump all keychains and search for the desired
  preferences.  All of the desired preferences have a line in the output like
  the following:

      "svce"<blob>="com.apple.network.eap.user.identity.wlan.ssid.<SSID>"

      or

      "svce"<blob>="com.apple.network.eap.system.identity.profileid.B7392191"

  Args:
    sudo_password: str, optional, for removing from system keychain
  """
  service_re = re.compile(r'\s*"svce"<blob>="(com\.apple\.network\.eap\..*)"')

  cmd = [CMD_SECURITY, 'dump-keychain']
  (keychain_content, _, _) = gmacpyutil.RunProcess(cmd)

  for line in keychain_content.splitlines():
    matches = service_re.match(line)
    if matches:
      cmd = [CMD_SECURITY, 'set-identity-preference',
             '-n', '-s', matches.group(1)]
      logging.debug('Removing identity preference: %s', cmd)
      gmacpyutil.RunProcess(cmd, sudo=bool(sudo_password),
                            sudo_password=sudo_password)


def CreateIdentityPreference(issuer_cn, service, keychain=login_keychain):
  """Create a TLS Identity preference for a given cert.

  Args:
    issuer_cn: str, CN of issuer of cert to create preference for
    service: str, Service for which identity should be preferred
    keychain: str, keychain to create the preference entry in

  Raises:
    CertError: if there is more than one matching cert
    CertError: if the preference can't be created
  """
  logging.debug('Creating TLS identity preference for %s in keychain %s',
                service, keychain)

  existing_certs = FindCertificates(issuer_cn=issuer_cn, keychain=keychain)
  if len(existing_certs) != 1:
    raise CertError('More or less than one matching certificate '
                    'in the keychain.')

  command = [CMD_SECURITY, 'set-identity-preference', '-Z',
             existing_certs[0].osx_fingerprint, '-s', service, keychain]
  logging.debug('Command: %s', command)
  (stdout, stderr, status) = gmacpyutil.RunProcess(command)
  logging.debug('Identity preference creation output: %s', stdout)
  if status:
    raise CertError(stdout, stderr)


def _GetSudoContext(keychain, gui=False, password=None):
  """Determine if we need sudo and get a sudo password if necessary.

  Args:
    keychain: path to keychain
    gui: True if we are running from the gui
    password: The user's password.

  Returns:
    sudo: True if this is the system keychain and we will need sudo
    sudo_pass: sudo password if necessary, or None if not necessary or not
               available.
  """

  sudo_pass = None
  if keychain == SYSTEM_KEYCHAIN:

    # If we're given a password we should test if it works for sudo.
    if password:
      (unused_stdout, unused_stderr, return_code) = gmacpyutil.RunProcess(
          ['-v'], sudo=True, sudo_password=password)
      if return_code == 0:
        return keychain == SYSTEM_KEYCHAIN, password

    if gui:
      # If we arrive here it means the password doesn't work for sudo, if this
      # is a gui context, try and get a passwd, otherwise let sudo do the
      # prompting in the terminal
      try:
        sudo_pass = getauth.GetPassword(
            gui=gui, title='Get sudo Password', prompt='sudo password',
            text='Enter local machine password')
      except (EOFError, KeyboardInterrupt):
        logging.exception('Could not get sudo password from GUI prompt')
        raise
  return keychain == SYSTEM_KEYCHAIN, sudo_pass


def InstallPrivateKeyInKeychain(private_key, keychain=login_keychain,
                                trusted_app_path=None, passphrase=None,
                                gui=False, password=None):
  """Install the private key into the keychain.

  Args:
    private_key: str, private key in PEM format
    keychain: str, keychain to install cert and key into
    trusted_app_path: list of strings, optional applications that can
                      access private key.  If None, trust is set Open
    passphrase: str, optional passphrase the private key is encrypted with
    gui: True if running in a gui context
    password: The user's password if already known.

  Raises:
    KeychainError: if there are any errors installing
  """
  sudo, sudo_pass = _GetSudoContext(keychain,
                                    gui=gui,
                                    password=password)

  temp_dir = tempfile.mkdtemp(prefix='cert_pkey_install')
  key_file = '%s/private.key' % temp_dir

  try:
    key_handle = open(key_file, 'w')
    key_handle.write(private_key)
    key_handle.close()

    logging.info('Installing downloaded key into the %s keychain', keychain)

    command = [CMD_SECURITY, 'import', key_file, '-x', '-k', keychain]
    if passphrase:
      command.extend(['-P', passphrase])
    if trusted_app_path:
      for trusted_app in trusted_app_path:
        if os.path.exists(trusted_app):
          command.extend(['-T', trusted_app])
    else:
      command.extend(['-A'])
    logging.debug('Command: %s', command)
    (stdout, stderr, status) = gmacpyutil.RunProcess(command, sudo=sudo,
                                                     sudo_password=sudo_pass)
    logging.debug('Private key installation output: %s', stdout)
    if status:
      raise KeychainError(stdout, stderr)
  except IOError:
    raise KeychainError('Could not write to temp files in %s' % temp_dir)
  finally:
    shutil.rmtree(temp_dir)


def InstallCertInKeychain(pem, private_key, keychain=login_keychain,
                          trusted_app_path=None, passphrase=None, gui=False,
                          password=None):
  """Install the certificate and private key into the keychain.

  Args:
    pem: str, the certificate in PEM format
    private_key: str, private key in PEM format
    keychain: str, keychain to install cert and key into
    trusted_app_path: list of strings, optional applications that can
                      access private key; a single string can be used for
                      backwards compatibility.  If None, trust is set Open
    passphrase: str, optional passphrase the private key is encrypted with
    gui: True if running in a gui context
    password: The user's password if already known.

  Raises:
    KeychainError: if there are any errors installing
  """
  sudo, sudo_pass = _GetSudoContext(keychain,
                                    gui=gui,
                                    password=password)

  if type(trusted_app_path) == str:
    # Convert bare strings to a list for backwards compatibility
    trusted_app_path = [trusted_app_path]

  # TODO(user): Make this function just install the certificate and not handle
  # any key installation at all.
  InstallPrivateKeyInKeychain(private_key, keychain=keychain,
                              trusted_app_path=trusted_app_path,
                              passphrase=passphrase, gui=gui,
                              password=sudo_pass)

  temp_dir = tempfile.mkdtemp(prefix='cert_install')
  cert_file = '%s/certificate.cer' % temp_dir

  try:
    cert_handle = open(cert_file, 'w')
    cert_handle.write(pem)
    cert_handle.close()

    logging.info('Installing downloaded certificate into the %s keychain',
                 keychain)

    command = [CMD_SECURITY, 'import', cert_file, '-x', '-k', keychain]
    logging.debug('Command: %s', command)
    (stdout, stderr, status) = gmacpyutil.RunProcess(command, sudo=sudo,
                                                     sudo_password=sudo_pass)
    logging.debug('Certificate installation output: %s', stdout)
    if status:
      raise KeychainError(stdout, stderr)
  except IOError:
    raise KeychainError('Could not write to temp files in %s' % temp_dir)
  finally:
    shutil.rmtree(temp_dir)


def InstallTrustedCertInKeychain(pem, root_ca=False, policies=None, gui=False,
                                 password=None, keychain=SYSTEM_KEYCHAIN):
  """Install a trusted certificate into the keychain.

  Args:
    pem: str, the certificate in PEM format
    root_ca: bool, whether the cert is a root CA cert (trustRoot vs trustAsRoot)
    policies: list of strings, optional policy constraints eg, ssl, basic, etc.
    gui: True if running in a gui context
    password: The user's password if already known
    keychain: str, keychain to install cert and key into

  Raises:
    KeychainError: if there are any errors installing
  """
  sudo, sudo_pass = _GetSudoContext(keychain,
                                    gui=gui,
                                    password=password)

  temp_dir = tempfile.mkdtemp(prefix='trusted_cert_install')
  trusted_cert_file = '%s/trusted_certificate.pem' % temp_dir

  try:
    with open(trusted_cert_file, 'w') as trusted_cert_handle:
      trusted_cert_handle.write(pem)

    logging.info('Installing trusted certificate into keychain: %s', keychain)

    command = [CMD_SECURITY, 'add-trusted-cert', '-d']
    if root_ca:
      command.extend(['-r', 'trustRoot'])
    else:
      command.extend(['-r', 'trustAsRoot'])

    if policies:
      for policy in policies:
        command.extend(['-p', policy])
    command.extend(['-k', keychain, trusted_cert_file])

    logging.debug('Command: %s', command)
    (stdout, stderr, status) = gmacpyutil.RunProcess(command, sudo=sudo,
                                                     sudo_password=sudo_pass)
    logging.debug('Trusted certificate installation output: %s', stdout)
    if status:
      raise KeychainError(stdout, stderr)
  except IOError:
    raise KeychainError('Could not write to temp files in %s' % temp_dir)
  finally:
    shutil.rmtree(temp_dir)


def RemoveIssuerCertsFromKeychain(issuer_cn, keychain=login_keychain, gui=False,
                                  password=None):
  """Removes all certificates issued from a given CN from the keychain.

  DeleteCert tries to raise privileges to allow deletions from the System
  keychain, so we try and log if it fails.

  Args:
    issuer_cn: str, the certificate's issuer's CN
    keychain: str, the path to the keychain to remove from
    gui: True if running in a gui context
    password: The user's password if already known.

  Raises:
    KeychainError: if there are any errors removing

  Returns:
    Array of deleted serial numbers
  """
  if keychain is None:
    return []

  existing_certs = FindCertificates(issuer_cn=issuer_cn, keychain=keychain)
  deleted_certs = []
  for cert in existing_certs:
    try:
      logging.debug('Removing cert with fingerprint %s from %s',
                    cert.osx_fingerprint, keychain)
      DeleteCert(cert.osx_fingerprint, keychain=keychain, gui=gui,
                 password=password)
      deleted_certs.append(cert.serial)
    except CertError, e:
      logging.error('Cannot delete old certificate: %s', str(e))
  return deleted_certs


def GenerateCSR(subject, rsa_bits=2048, passphrase=None):
  """Generate a Certificate Signing Request.

  Args:
    subject: str, subject for csr
    rsa_bits: int, optional number of bits
    passphrase: str, optional passphrase to encrypt private key with

  Returns:
    tuple, cert and private key in PEM format and passphrase

  Raises:
    CertError: Error generating a CSR.
  """
  command = [CMD_OPENSSL, 'genrsa']
  env = {}
  if passphrase:
    command.extend(['-des3', '-passout', 'env:PASSPHRASE'])
    env['PASSPHRASE'] = passphrase
  command.extend([str(rsa_bits)])

  logging.debug('command: %s', command)
  logging.debug('environment: %s', env.keys())
  (stdout, stderr, status) = gmacpyutil.RunProcess(command, env=env)
  logging.debug('Private key generation output: %s', stdout)
  if status:
    raise CertError('Error creating private key: %s' % stderr)
  private_key = stdout

  command = [CMD_OPENSSL, 'req', '-new', '-subj', subject, '-key',
             '/dev/stdin']
  if passphrase:
    command.extend(['-passin', 'env:PASSPHRASE'])
    env['PASSPHRASE'] = passphrase

  logging.debug('command: %s', command)
  logging.debug('environment: %s', env.keys())
  logging.debug('stdinput: %s', private_key)
  (csr, stderr, status) = gmacpyutil.RunProcess(command, private_key, env=env)
  logging.debug('CSR generation output: %s', csr)
  if status:
    raise CertError('Error creating CSR: %s' % stderr)

  return (csr, private_key, passphrase)


LoginKeychain()