/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at
 * trunk/opends/resource/legal-notices/OpenDS.LICENSE
 * or https://OpenDS.dev.java.net/OpenDS.LICENSE.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at
 * trunk/opends/resource/legal-notices/OpenDS.LICENSE.  If applicable,
 * add the following below this CDDL HEADER, with the fields enclosed
 * by brackets "[]" replaced with your own identifying information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2008-2009 Sun Microsystems, Inc.
 *      Portions Copyright 2009 Parametric Technology Corporation (PTC)
 */

package org.opends.admin.ads.util;

import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;

import org.opends.server.util.Platform;

/**
 * This class is in charge of checking whether the certificates that are
 * presented are trusted or not.
 * This implementation tries to check also that the subject DN of the
 * certificate corresponds to the host passed using the setHostName method.
 *
 * The constructor tries to use a default TrustManager from the system and if
 * it cannot be retrieved this class will only accept the certificates
 * explicitly accepted by the user (and specified by calling acceptCertificate).
 *
 * NOTE: this class is not aimed to be used when we have connections in
 * parallel.
 */
public class ApplicationTrustManager implements X509TrustManager
{
  /**
   * The enumeration for the different causes for which the trust manager can
   * refuse to accept a certificate.
   */
  public enum Cause
  {
    /**
     * The certificate was not trusted.
     */
    NOT_TRUSTED,
    /**
     * The certificate's subject DN's value and the host name we tried to
     * connect to do not match.
     */
    HOST_NAME_MISMATCH
  }
  static private final Logger LOG =
    Logger.getLogger(ApplicationTrustManager.class.getName());

  private X509TrustManager trustManager;
  private String lastRefusedAuthType;
  private X509Certificate[] lastRefusedChain;
  private Cause lastRefusedCause = null;
  private KeyStore keystore = null;

  /*
   * The following ArrayList contain information about the certificates
   * explicitly accepted by the user.
   */
  private ArrayList<X509Certificate[]> acceptedChains =
    new ArrayList<X509Certificate[]>();
  private ArrayList<String> acceptedAuthTypes = new ArrayList<String>();
  private ArrayList<String> acceptedHosts = new ArrayList<String>();

  private String host;


  /**
   * The default constructor.
   *
   * @param keystore The keystore to use for this trustmanager.
   */
  public ApplicationTrustManager(KeyStore keystore)
  {
    TrustManagerFactory tmf = null;
    this.keystore = keystore;
    String userSpecifiedAlgo =
      System.getProperty("org.opends.admin.trustmanageralgo");
    String userSpecifiedProvider =
      System.getProperty("org.opends.admin.trustmanagerprovider");

    //Handle IBM specific cases if the user did not specify a algorithm and/or
    //provider.
    if(userSpecifiedAlgo == null && Platform.isVendor("IBM"))
      userSpecifiedAlgo = "IbmX509";
    if(userSpecifiedProvider == null && Platform.isVendor("IBM"))
      userSpecifiedProvider = "IBMJSSE2";

    // Have some fallbacks to choose the provider and algorith of the key
    // manager.  First see if the user wanted to use something specific,
    // then try with the SunJSSE provider and SunX509 algorithm. Finally,
    // fallback to the default algorithm of the JVM.
    String[] preferredProvider =
    {
        userSpecifiedProvider,
        "SunJSSE",
        null,
        null
    };
    String[] preferredAlgo =
    {
        userSpecifiedAlgo,
        "SunX509",
        "SunX509",
        TrustManagerFactory.getDefaultAlgorithm()
    };
      for (int i=0; i<preferredProvider.length && trustManager == null; i++)
      {
        String provider = preferredProvider[i];
        String algo = preferredAlgo[i];
        if (algo == null)
        {
          continue;
        }
        try
        {
          if (provider != null)
          {
            tmf = TrustManagerFactory.getInstance(algo, provider);
          }
          else
          {
            tmf = TrustManagerFactory.getInstance(algo);
          }
          tmf.init(keystore);
          TrustManager[] trustManagers = tmf.getTrustManagers();
          for (int j=0; j < trustManagers.length; j++)
          {
            if (trustManagers[j] instanceof X509TrustManager)
            {
              trustManager = (X509TrustManager)trustManagers[j];
              break;
            }
          }
        }
        catch (NoSuchProviderException e)
        {
          LOG.log(Level.WARNING, "Error with the provider: "+provider, e);
        }
        catch (NoSuchAlgorithmException e)
        {
          LOG.log(Level.WARNING, "Error with the algorithm: "+algo, e);
        }
        catch (KeyStoreException e)
        {
          LOG.log(Level.WARNING, "Error with the keystore", e);
        }
      }
  }

  /**
   * {@inheritDoc}
   */
  public void checkClientTrusted(X509Certificate[] chain, String authType)
  throws CertificateException
  {
    boolean explicitlyAccepted = false;
    try
    {
      if (trustManager != null)
      {
        try
        {
          trustManager.checkClientTrusted(chain, authType);
        }
        catch (CertificateException ce)
        {
          verifyAcceptedCertificates(chain, authType);
          explicitlyAccepted = true;
        }
      }
      else
      {
        verifyAcceptedCertificates(chain, authType);
        explicitlyAccepted = true;
      }
    }
    catch (CertificateException ce)
    {
      lastRefusedChain = chain;
      lastRefusedAuthType = authType;
      lastRefusedCause = Cause.NOT_TRUSTED;
      OpendsCertificateException e = new OpendsCertificateException(
          chain);
      e.initCause(ce);
      throw e;
    }

    if (!explicitlyAccepted)
    {
      try
      {
        verifyHostName(chain, authType);
      }
      catch (CertificateException ce)
      {
        lastRefusedChain = chain;
        lastRefusedAuthType = authType;
        lastRefusedCause = Cause.HOST_NAME_MISMATCH;
        OpendsCertificateException e = new OpendsCertificateException(
            chain);
        e.initCause(ce);
        throw e;
      }
    }
  }

  /**
   * {@inheritDoc}
   */
  public void checkServerTrusted(X509Certificate[] chain,
      String authType) throws CertificateException
  {
    boolean explicitlyAccepted = false;
    try
    {
      if (trustManager != null)
      {
        try
        {
          trustManager.checkServerTrusted(chain, authType);
        }
        catch (CertificateException ce)
        {
          verifyAcceptedCertificates(chain, authType);
          explicitlyAccepted = true;
        }
      }
      else
      {
        verifyAcceptedCertificates(chain, authType);
        explicitlyAccepted = true;
      }
    }
    catch (CertificateException ce)
    {
      lastRefusedChain = chain;
      lastRefusedAuthType = authType;
      lastRefusedCause = Cause.NOT_TRUSTED;
      OpendsCertificateException e = new OpendsCertificateException(chain);
      e.initCause(ce);
      throw e;
    }

    if (!explicitlyAccepted)
    {
      try
      {
        verifyHostName(chain, authType);
      }
      catch (CertificateException ce)
      {
        lastRefusedChain = chain;
        lastRefusedAuthType = authType;
        lastRefusedCause = Cause.HOST_NAME_MISMATCH;
        OpendsCertificateException e = new OpendsCertificateException(
            chain);
        e.initCause(ce);
        throw e;
      }
    }
  }

  /**
   * {@inheritDoc}
   */
  public X509Certificate[] getAcceptedIssuers()
  {
    if (trustManager != null)
    {
      return trustManager.getAcceptedIssuers();
    }
    else
    {
      return new X509Certificate[0];
    }
  }

  /**
   * This method is called when the user accepted a certificate.
   * @param chain the certificate chain accepted by the user.
   * @param authType the authentication type.
   * @param host the host we tried to connect and that presented the
   * certificate.
   */
  public void acceptCertificate(X509Certificate[] chain, String authType,
      String host)
  {
    acceptedChains.add(chain);
    acceptedAuthTypes.add(authType);
    acceptedHosts.add(host);
  }

  /**
   * Sets the host name we are trying to contact in a secure mode.  This
   * method is used if we want to verify the correspondance between the
   * hostname and the subject DN of the certificate that is being presented.
   * If this method is never called (or called passing null) no verification
   * will be made on the host name.
   * @param host the host name we are trying to contact in a secure mode.
   */
  public void setHost(String host)
  {
    this.host = host;
  }

  /**
   * This is a method used to set to null the different members that provide
   * information about the last refused certificate.  It is recommended to
   * call this method before trying to establish a connection using this
   * trust manager.
   */
  public void resetLastRefusedItems()
  {
    lastRefusedAuthType = null;
    lastRefusedChain = null;
    lastRefusedCause = null;
  }

  /**
   * Creates a copy of this ApplicationTrustManager.
   * @return a copy of this ApplicationTrustManager.
   */
  public ApplicationTrustManager createCopy()
  {
    ApplicationTrustManager copy = new ApplicationTrustManager(keystore);
    copy.lastRefusedAuthType = lastRefusedAuthType;
    copy.lastRefusedChain = lastRefusedChain;
    copy.lastRefusedCause = lastRefusedCause;
    copy.acceptedChains.addAll(acceptedChains);
    copy.acceptedAuthTypes.addAll(acceptedAuthTypes);
    copy.acceptedHosts.addAll(acceptedHosts);

    copy.host = host;

    return copy;
  }

  /**
   * Verifies whether the provided chain and authType have been already accepted
   * by the user or not.  If they have not a CertificateException is thrown.
   * @param chain the certificate chain to analyze.
   * @param authType the authentication type.
   * @throws CertificateException if the provided certificate chain and the
   * authentication type have not been accepted explicitly by the user.
   */
  private void verifyAcceptedCertificates(X509Certificate[] chain,
      String authType) throws CertificateException
  {
    boolean found = false;
    for (int i=0; i<acceptedChains.size() && !found; i++)
    {
      if (authType.equals(acceptedAuthTypes.get(i)))
      {
        X509Certificate[] current = acceptedChains.get(i);
        found = current.length == chain.length;
        for (int j=0; j<chain.length && found; j++)
        {
          found = chain[j].equals(current[j]);
        }
      }
    }
    if (!found)
    {
      throw new OpendsCertificateException(
          "Certificate not in list of accepted certificates", chain);
    }
  }

  /**
   * Verifies that the provided certificate chains subject DN corresponds to the
   * host name specified with the setHost method.
   * @param chain the certificate chain to analyze.
   * @throws CertificateException if the subject DN of the certificate does
   * not match with the host name specified with the method setHost.
   */
  private void verifyHostName(X509Certificate[] chain, String authType)
  throws CertificateException
  {
    if (host != null)
    {
      boolean matches = false;
      try
      {
        LdapName dn =
          new LdapName(chain[0].getSubjectX500Principal().getName());
        Rdn rdn = dn.getRdn(dn.getRdns().size() - 1);
        String value = rdn.getValue().toString();
        matches = hostMatch(value, host);
        if (!matches)
        {
          LOG.log(Level.WARNING, "Subject DN RDN value is: "+value+
              " and does not match host value: "+host);
          // Try with the accepted hosts names
          for (int i =0; i<acceptedHosts.size() && !matches; i++)
          {
            if (hostMatch(acceptedHosts.get(i), host))
            {
              X509Certificate[] current = acceptedChains.get(i);
              matches = current.length == chain.length;
              for (int j=0; j<chain.length && matches; j++)
              {
                matches = chain[j].equals(current[j]);
              }
            }
          }
        }
      }
      catch (Throwable t)
      {
        LOG.log(Level.WARNING, "Error parsing subject dn: "+
            chain[0].getSubjectX500Principal(), t);
      }

      if (!matches)
      {
        throw new OpendsCertificateException(
            "Hostname mismatch between host name " + host
                + " and subject DN: " + chain[0].getSubjectX500Principal(),
            chain);
      }
    }
  }

  /**
   * Returns the authentication type for the last refused certificate.
   * @return the authentication type for the last refused certificate.
   */
  public String getLastRefusedAuthType()
  {
    return lastRefusedAuthType;
  }

  /**
   * Returns the last cause for refusal of a certificate.
   * @return the last cause for refusal of a certificate.
   */
  public Cause getLastRefusedCause()
  {
    return lastRefusedCause;
  }

  /**
   * Returns the certificate chain for the last refused certificate.
   * @return the certificate chain for the last refused certificate.
   */
  public X509Certificate[] getLastRefusedChain()
  {
    return lastRefusedChain;
  }

  /**
   * Checks whether two host names match.  It accepts the use of wildcard in the
   * host name.
   * @param host1 the first host name.
   * @param host2 the second host name.
   * @return <CODE>true</CODE> if the host match and <CODE>false</CODE>
   * otherwise.
   */
  private boolean hostMatch(String host1, String host2)
  {
    if (host1 == null)
    {
      throw new IllegalArgumentException("The host1 parameter cannot be null");
    }
    if (host2 == null)
    {
      throw new IllegalArgumentException("The host2 parameter cannot be null");
    }
    String[] h1 = host1.split("\\.");
    String[] h2 = host2.split("\\.");

    boolean hostMatch = h1.length == h2.length;
    for (int i=0; i<h1.length && hostMatch; i++)
    {
      if (!h1[i].equals("*") && !h2.equals("*"))
      {
        hostMatch = h1[i].equalsIgnoreCase(h2[i]);
      }
    }
    return hostMatch;
  }
}
