/* See LICENSE for licensing and NOTICE for copyright. */
package org.ldaptive.ssl;

import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLSession;
import org.ldaptive.LdapUtils;
import org.ldaptive.asn1.DefaultDERBuffer;
import org.ldaptive.dn.Dn;
import org.ldaptive.dn.NameValue;
import org.ldaptive.dn.RDn;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Hostname verifier that provides an implementation similar to what occurs with JNDI startTLS. Verification occurs in
 * the following order:
 *
 * <ul>
 *   <li>if hostname is IP, then cert must have exact match IP subjAltName</li>
 *   <li>hostname must match any DNS subjAltName if any exist</li>
 *   <li>hostname must match the first CN</li>
 *   <li>if cert begins with a wildcard, domains are used for matching</li>
 * </ul>
 *
 * @author  Middleware Services
 */
public class DefaultHostnameVerifier implements HostnameVerifier, CertificateHostnameVerifier
{

  /** Enum for subject alt name types. */
  private enum SubjectAltNameType {

    /** other name (0). */
    OTHER_NAME,

    /** ref822 name (1). */
    RFC822_NAME,

    /** dns name (2). */
    DNS_NAME,

    /** x400 address (3). */
    X400_ADDRESS,

    /** directory name (4). */
    DIRECTORY_NAME,

    /** edi party name (5). */
    EDI_PARTY_NAME,

    /** uniform resource identifier (6). */
    UNIFORM_RESOURCE_IDENTIFIER,

    /** ip address (7). */
    IP_ADDRESS,

    /** registered id (8). */
    REGISTERED_ID
  }

  /** Logger for this class. */
  protected final Logger logger = LoggerFactory.getLogger(getClass());

  /** Hostname verifier delegate. */
  private final HostnameVerifier verifier = new HostnameVerifierAdapter(this);


  @Override
  public boolean verify(final String hostname, final SSLSession session)
  {
    return verifier.verify(hostname, session);
  }


  /**
   * Verify if the hostname is an IP address using {@link LdapUtils#isIPAddress(String)}. Delegates to {@link
   * #verifyIP(String, X509Certificate)} and {@link #verifyDNS(String, X509Certificate)} accordingly.
   *
   * @param  hostname  to verify
   * @param  cert  to verify hostname against
   *
   * @return  whether hostname is valid for the supplied certificate
   */
  @Override
  public boolean verify(final String hostname, final X509Certificate cert)
  {
    final boolean b;
    if (LdapUtils.isIPAddress(hostname)) {
      b = verifyIP(hostname, cert);
    } else {
      b = verifyDNS(hostname, cert);
    }
    logger.debug("Verify is {} with hostname={} against cert={}", b, hostname, cert.getSubjectX500Principal());
    return b;
  }


  /**
   * Verify the certificate allows use of the supplied IP address.
   *
   * <p>From RFC2818: In some cases, the URI is specified as an IP address rather than a hostname. In this case, the
   * iPAddress subjectAltName must be present in the certificate and must exactly match the IP in the URI.</p>
   *
   * @param  ip  address to match in the certificate
   * @param  cert  to inspect for the IP address
   *
   * @return  whether the ip matched a subject alt name
   */
  protected boolean verifyIP(final String ip, final X509Certificate cert)
  {
    final String[] subjAltNames = getSubjectAltNames(cert, SubjectAltNameType.IP_ADDRESS);
    logger.trace("verifyIP using subjectAltNames={}", Arrays.toString(subjAltNames));
    for (String name : subjAltNames) {
      if (ip.equalsIgnoreCase(name)) {
        logger.trace("verifyIP found hostname match: {}", name);
        return true;
      }
    }
    return false;
  }


  /**
   * Verify the certificate allows use of the supplied DNS name. Note that only the first CN is used.
   *
   * <p>From RFC2818: If a subjectAltName extension of type dNSName is present, that MUST be used as the identity.
   * Otherwise, the (most specific) Common Name field in the Subject field of the certificate MUST be used. Although the
   * use of the Common Name is existing practice, it is deprecated and Certification Authorities are encouraged to use
   * the dNSName instead.</p>
   *
   * <p>Matching is performed using the matching rules specified by [RFC2459]. If more than one identity of a given type
   * is present in the certificate (e.g., more than one dNSName name, a match in any one of the set is considered
   * acceptable.)</p>
   *
   * @param  hostname  to match in the certificate
   * @param  cert  to inspect for the hostname
   *
   * @return  whether the hostname matched a subject alt name or CN
   */
  protected boolean verifyDNS(final String hostname, final X509Certificate cert)
  {
    boolean verified = false;
    final String[] subjAltNames = getSubjectAltNames(cert, SubjectAltNameType.DNS_NAME);
    logger.trace("verifyDNS using subjectAltNames={}", Arrays.toString(subjAltNames));
    if (subjAltNames.length > 0) {
      // if subject alt names exist, one must match
      for (String name : subjAltNames) {
        if (isMatch(hostname, name)) {
          logger.trace("verifyDNS found hostname match: {}", name);
          verified = true;
          break;
        }
      }
    } else {
      final String[] cns = getCNs(cert);
      logger.trace("verifyDNS using CN={}", Arrays.toString(cns));
      if (cns.length > 0) {
        // the most specific CN refers to the last CN
        if (isMatch(hostname, cns[cns.length - 1])) {
          logger.trace("verifyDNS found hostname match: {}", cns[cns.length - 1]);
          verified = true;
        }
      }
    }
    return verified;
  }


  /**
   * Returns the subject alternative names matching the supplied name type from the supplied certificate.
   *
   * @param  cert  to get subject alt names from
   * @param  type  subject alt name type
   *
   * @return  subject alt names
   */
  private String[] getSubjectAltNames(final X509Certificate cert, final SubjectAltNameType type)
  {
    final List<String> names = new ArrayList<>();
    try {
      final Collection<List<?>> subjAltNames = cert.getSubjectAlternativeNames();
      if (subjAltNames != null) {
        for (List<?> generalName : subjAltNames) {
          final Integer nameType = (Integer) generalName.get(0);
          if (nameType == type.ordinal()) {
            names.add((String) generalName.get(1));
          }
        }
      }
    } catch (CertificateParsingException e) {
      logger.warn("Error reading subject alt names from certificate", e);
    }
    return names.toArray(new String[0]);
  }


  /**
   * Returns the CNs from the supplied certificate.
   *
   * @param  cert  to get CNs from
   *
   * @return  CNs
   */
  private String[] getCNs(final X509Certificate cert)
  {
    final List<String> names = new ArrayList<>();
    final byte[] encodedDn = cert.getSubjectX500Principal().getEncoded();
    if (encodedDn != null && encodedDn.length > 0) {
      final X509DnDecoder decoder = new X509DnDecoder();
      final Dn dn = decoder.apply(new DefaultDERBuffer(encodedDn));
      for (RDn rdn : dn.getRDns()) {
        // for multi value RDNs the first value is used
        final NameValue nameValue = rdn.getNameValue("2.5.4.3");
        if (nameValue != null) {
          names.add(nameValue.getStringValue());
        }
      }
    }
    return names.toArray(new String[0]);
  }


  /**
   * Determines if the supplied hostname matches a name derived from the certificate. If the certificate name starts
   * with '*', the domain components after the first '.' in each name are compared.
   *
   * @param  hostname  to match
   * @param  certName  to match
   *
   * @return  whether the hostname matched the cert name
   */
  private boolean isMatch(final String hostname, final String certName)
  {
    // must start with '*' and contain two domain components
    final boolean isWildcard = certName.startsWith("*.") && certName.indexOf('.') < certName.lastIndexOf('.');
    logger.trace("matching for hostname={}, certName={}, isWildcard={}", hostname, certName, isWildcard);

    final boolean match;
    if (isWildcard) {
      final String certNameDomain = certName.substring(certName.indexOf("."));

      final int hostnameIdx = hostname.contains(".") ? hostname.indexOf(".") : hostname.length();
      final String hostnameDomain = hostname.substring(hostnameIdx);

      match = certNameDomain.equalsIgnoreCase(hostnameDomain);
      logger.trace("match={} for {} == {}", match, certNameDomain, hostnameDomain);
    } else {
      match = certName.equalsIgnoreCase(hostname);
      logger.trace("match={} for {} == {}", match, certName, hostname);
    }
    return match;
  }
}
