/* See LICENSE for licensing and NOTICE for copyright. */
package org.cryptacular.util;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.GeneralNamesBuilder;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.PolicyInformation;
import org.cryptacular.EncodingException;
import org.cryptacular.StreamException;
import org.cryptacular.x509.ExtensionReader;
import org.cryptacular.x509.GeneralNameType;
import org.cryptacular.x509.KeyUsageBits;
import org.cryptacular.x509.dn.NameReader;
import org.cryptacular.x509.dn.StandardAttributeType;

/**
 * Utility class providing convenience methods for common operations on X.509 certificates.
 *
 * @author  Middleware Services
 */
public final class CertUtil
{

  /** Private constructor of utility class. */
  private CertUtil() {}


  /**
   * Gets the common name attribute (CN) of the certificate subject distinguished name.
   *
   * @param  cert  Certificate to examine.
   *
   * @return  Subject CN or null if no CN attribute is defined in the subject DN.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static String subjectCN(final X509Certificate cert) throws EncodingException
  {
    return new NameReader(cert).readSubject().getValue(StandardAttributeType.CommonName);
  }


  /**
   * Gets all subject alternative names defined on the given certificate.
   *
   * @param  cert  X.509 certificate to examine.
   *
   * @return  List of subject alternative names or null if no subject alt names are defined.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static GeneralNames subjectAltNames(final X509Certificate cert) throws EncodingException
  {
    return new ExtensionReader(cert).readSubjectAlternativeName();
  }


  /**
   * Gets all subject alternative names of the given type(s) on the given cert.
   *
   * @param  cert  X.509 certificate to examine.
   * @param  types  One or more subject alternative name types to fetch.
   *
   * @return  List of subject alternative names of the matching type(s) or null if none found.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static GeneralNames subjectAltNames(final X509Certificate cert, final GeneralNameType... types)
    throws EncodingException
  {
    final GeneralNamesBuilder builder = new GeneralNamesBuilder();
    final GeneralNames altNames = subjectAltNames(cert);
    if (altNames != null) {
      for (GeneralName name : altNames.getNames()) {
        for (GeneralNameType type : types) {
          if (type.ordinal() == name.getTagNo()) {
            builder.addName(name);
          }
        }
      }
    }

    final GeneralNames names = builder.build();
    if (names.getNames().length == 0) {
      return null;
    }
    return names;
  }


  /**
   * Gets a list of all subject names defined for the given certificate. The list includes the first common name (CN)
   * specified in the subject distinguished name (if defined) and all subject alternative names.
   *
   * @param  cert  X.509 certificate to examine.
   *
   * @return  List of subject names.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static List<String> subjectNames(final X509Certificate cert) throws EncodingException
  {
    final List<String> names = new ArrayList<>();
    final String cn = subjectCN(cert);
    if (cn != null) {
      names.add(cn);
    }

    final GeneralNames altNames = subjectAltNames(cert);
    if (altNames == null) {
      return names;
    }
    for (GeneralName name : altNames.getNames()) {
      names.add(name.getName().toString());
    }
    return names;
  }


  /**
   * Gets a list of subject names defined for the given certificate. The list includes the first common name (CN)
   * specified in the subject distinguished name (if defined) and all subject alternative names of the given type.
   *
   * @param  cert  X.509 certificate to examine.
   * @param  types  One or more subject alternative name types to fetch.
   *
   * @return  List of subject names.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static List<String> subjectNames(final X509Certificate cert, final GeneralNameType... types)
    throws EncodingException
  {
    final List<String> names = new ArrayList<>();
    final String cn = subjectCN(cert);
    if (cn != null) {
      names.add(cn);
    }

    final GeneralNames altNames = subjectAltNames(cert, types);
    if (altNames == null) {
      return names;
    }
    for (GeneralName name : altNames.getNames()) {
      names.add(name.getName().toString());
    }
    return names;
  }


  /**
   * Finds a certificate whose public key is paired with the given private key.
   *
   * @param  key  Private key used to find matching public key.
   * @param  candidates  Array of candidate certificates.
   *
   * @return  Certificate whose public key forms a keypair with the private key or null if no match is found.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static X509Certificate findEntityCertificate(final PrivateKey key, final X509Certificate... candidates)
    throws EncodingException
  {
    return findEntityCertificate(key, Arrays.asList(candidates));
  }


  /**
   * Finds a certificate whose public key is paired with the given private key.
   *
   * @param  key  Private key used to find matching public key.
   * @param  candidates  Collection of candidate certificates.
   *
   * @return  Certificate whose public key forms a keypair with the private key or null if no match is found.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static X509Certificate findEntityCertificate(
    final PrivateKey key,
    final Collection<X509Certificate> candidates)
    throws EncodingException
  {
    for (X509Certificate candidate : candidates) {
      if (KeyPairUtil.isKeyPair(candidate.getPublicKey(), key)) {
        return candidate;
      }
    }
    return null;
  }


  /**
   * Reads an X.509 certificate from ASN.1 encoded format in the file at the given location.
   *
   * @param  path  Path to file containing an DER or PEM encoded X.509 certificate.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   * @throws  StreamException  on IO errors.
   */
  public static X509Certificate readCertificate(final String path) throws EncodingException, StreamException
  {
    return readCertificate(StreamUtil.makeStream(new File(path)));
  }


  /**
   * Reads an X.509 certificate from ASN.1 encoded format from the given file.
   *
   * @param  file  File containing an DER or PEM encoded X.509 certificate.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   * @throws  StreamException  on IO errors.
   */
  public static X509Certificate readCertificate(final File file) throws EncodingException, StreamException
  {
    return readCertificate(StreamUtil.makeStream(file));
  }


  /**
   * Reads an X.509 certificate from ASN.1 encoded data in the given stream.
   *
   * @param  in  Input stream containing PEM or DER encoded X.509 certificate.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   * @throws  StreamException  on IO errors.
   */
  public static X509Certificate readCertificate(final InputStream in) throws EncodingException, StreamException
  {
    try {
      final CertificateFactory factory = CertificateFactory.getInstance("X.509");
      return (X509Certificate) factory.generateCertificate(in);
    } catch (CertificateException e) {
      if (e.getCause() instanceof IOException) {
        throw new StreamException((IOException) e.getCause());
      }
      throw new EncodingException("Cannot decode certificate", e);
    }
  }


  /**
   * Creates an X.509 certificate from its ASN.1 encoded form.
   *
   * @param  encoded  PEM or DER encoded ASN.1 data.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   */
  public static X509Certificate decodeCertificate(final byte[] encoded) throws EncodingException
  {
    return readCertificate(new ByteArrayInputStream(encoded));
  }


  /**
   * Reads an X.509 certificate chain from ASN.1 encoded format in the file at the given location.
   *
   * @param  path  Path to file containing a sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   * @throws  StreamException  on IO errors.
   */
  public static X509Certificate[] readCertificateChain(final String path) throws EncodingException, StreamException
  {
    return readCertificateChain(StreamUtil.makeStream(new File(path)));
  }


  /**
   * Reads an X.509 certificate chain from ASN.1 encoded format from the given file.
   *
   * @param  file  File containing a sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   * @throws  StreamException  on IO errors.
   */
  public static X509Certificate[] readCertificateChain(final File file) throws EncodingException, StreamException
  {
    return readCertificateChain(StreamUtil.makeStream(file));
  }


  /**
   * Reads an X.509 certificate chain from ASN.1 encoded data in the given stream.
   *
   * @param  in  Input stream containing a sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   * @throws  StreamException  on IO errors.
   */
  public static X509Certificate[] readCertificateChain(final InputStream in) throws EncodingException, StreamException
  {
    try {
      final CertificateFactory factory = CertificateFactory.getInstance("X.509");
      final Collection<? extends Certificate> certs = factory.generateCertificates(in);
      return certs.toArray(new X509Certificate[certs.size()]);
    } catch (CertificateException e) {
      if (e.getCause() instanceof IOException) {
        throw new StreamException((IOException) e.getCause());
      }
      throw new EncodingException("Cannot decode certificate", e);
    }
  }


  /**
   * Creates an X.509 certificate chain from its ASN.1 encoded form.
   *
   * @param  encoded  Sequence of PEM or DER encoded certificates or PKCS#7 certificate chain.
   *
   * @return  Certificate.
   *
   * @throws  EncodingException  on cert parsing errors.
   */
  public static X509Certificate[] decodeCertificateChain(final byte[] encoded) throws EncodingException
  {
    return readCertificateChain(new ByteArrayInputStream(encoded));
  }


  /**
   * Determines whether the certificate allows the given basic key usages.
   *
   * @param  cert  Certificate to check.
   * @param  bits  One or more basic key usage types to check.
   *
   * @return  True if certificate allows all given usage types, false otherwise.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static boolean allowsUsage(final X509Certificate cert, final KeyUsageBits... bits) throws EncodingException
  {
    final KeyUsage usage = new ExtensionReader(cert).readKeyUsage();
    for (KeyUsageBits bit : bits) {
      if (!bit.isSet(usage)) {
        return false;
      }
    }
    return true;
  }


  /**
   * Determines whether the certificate allows the given extended key usages.
   *
   * @param  cert  Certificate to check.
   * @param  purposes  One ore more extended key usage purposes to check.
   *
   * @return  True if certificate allows all given purposes, false otherwise.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static boolean allowsUsage(final X509Certificate cert, final KeyPurposeId... purposes) throws EncodingException
  {
    final List<KeyPurposeId> allowedUses = new ExtensionReader(cert).readExtendedKeyUsage();
    for (KeyPurposeId purpose : purposes) {
      if (allowedUses == null || !allowedUses.contains(purpose)) {
        return false;
      }
    }
    return true;
  }


  /**
   * Determines whether the certificate defines all of the given certificate policies.
   *
   * @param  cert  Certificate to check.
   * @param  policyOidsToCheck  One or more certificate policy OIDs to check.
   *
   * @return  True if certificate defines all given policy OIDs, false otherwise.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static boolean hasPolicies(final X509Certificate cert, final String... policyOidsToCheck)
    throws EncodingException
  {
    final List<PolicyInformation> policies = new ExtensionReader(cert).readCertificatePolicies();
    boolean hasPolicy;
    for (String policyOid : policyOidsToCheck) {
      hasPolicy = false;
      if (policies != null) {
        for (PolicyInformation policy : policies) {
          if (policy.getPolicyIdentifier().getId().equals(policyOid)) {
            hasPolicy = true;
            break;
          }
        }
      }
      if (!hasPolicy) {
        return false;
      }
    }
    return true;
  }


  /**
   * Gets the subject key identifier of the given certificate in delimited hexadecimal format, e.g. <code>
   * 25:48:2f:28:ec:5d:19:bb:1d:25:ae:94:93:b1:7b:b5:35:96:24:66</code>.
   *
   * @param  cert  Certificate to process.
   *
   * @return  Subject key identifier in colon-delimited hex format.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static String subjectKeyId(final X509Certificate cert) throws EncodingException
  {
    return CodecUtil.hex(new ExtensionReader(cert).readSubjectKeyIdentifier().getKeyIdentifier(), true);
  }


  /**
   * Gets the authority key identifier of the given certificate in delimited hexadecimal format, e.g. <code>
   * 25:48:2f:28:ec:5d:19:bb:1d:25:ae:94:93:b1:7b:b5:35:96:24:66</code>.
   *
   * @param  cert  Certificate to process.
   *
   * @return  Authority key identifier in colon-delimited hex format.
   *
   * @throws  EncodingException  on cert field extraction.
   */
  public static String authorityKeyId(final X509Certificate cert) throws EncodingException
  {
    return CodecUtil.hex(new ExtensionReader(cert).readAuthorityKeyIdentifier().getKeyIdentifier(), true);
  }
}
