// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2012-2014 Monty Program Ab
// Copyright (c) 2015-2024 MariaDB Corporation Ab
package org.mariadb.jdbc.client.tls;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.regex.Pattern;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.net.ssl.SSLException;
import javax.security.auth.x500.X500Principal;
import org.mariadb.jdbc.util.log.Logger;
import org.mariadb.jdbc.util.log.Loggers;

/** SSL host verification */
public class HostnameVerifier {

  private static final Logger logger = Loggers.getLogger(HostnameVerifier.class);
  private static final Pattern IP_V4 =
      Pattern.compile(
          "^(([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.)"
              + "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){2}"
              + "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$");
  private static final Pattern IP_V6 = Pattern.compile("^[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){7}$");
  private static final Pattern IP_V6_COMPRESSED =
      Pattern.compile(
          "^(([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,5})?)"
              + "::(([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,5})?)$");

  /**
   * DNS verification : 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.) Names may contain the
   * wildcard character * which is considered to match any single domain name component or component
   * fragment. E.g., *.a.com matches foo.a.com but not bar.foo.a.com. f*.com matches foo.com but not
   * bar.com.
   *
   * @param hostname hostname
   * @param tlsDnsPattern DNS pattern (may contain wildcard)
   * @return true if matching
   */
  private static boolean matchDns(String hostname, String tlsDnsPattern) throws SSLException {
    boolean hostIsIp = isIPv4(hostname) || isIPv6(hostname);
    StringTokenizer hostnameSt = new StringTokenizer(hostname.toLowerCase(Locale.ROOT), ".");
    StringTokenizer templateSt = new StringTokenizer(tlsDnsPattern.toLowerCase(Locale.ROOT), ".");
    if (hostnameSt.countTokens() != templateSt.countTokens()) {
      return false;
    }

    try {
      while (hostnameSt.hasMoreTokens()) {
        if (!matchWildCards(hostIsIp, hostnameSt.nextToken(), templateSt.nextToken())) {
          return false;
        }
      }
    } catch (SSLException exception) {
      throw new SSLException(
          normalizedHostMsg(hostname)
              + " doesn't correspond to certificate CN \""
              + tlsDnsPattern
              + "\" : wildcards not possible for IPs");
    }
    return true;
  }

  private static boolean matchWildCards(boolean hostIsIp, String hostnameToken, String tlsDnsToken)
      throws SSLException {
    int wildcardIndex = tlsDnsToken.indexOf("*");
    String token = hostnameToken;
    if (wildcardIndex != -1) {
      if (hostIsIp) {
        throw new SSLException("WildCards not possible when using IP's");
      }
      boolean first = true;
      String beforeWildcard;
      String afterWildcard = tlsDnsToken;

      while (wildcardIndex != -1) {
        beforeWildcard = afterWildcard.substring(0, wildcardIndex);
        afterWildcard = afterWildcard.substring(wildcardIndex + 1);

        int beforeStartIdx = token.indexOf(beforeWildcard);
        if ((beforeStartIdx == -1) || (first && beforeStartIdx != 0)) {
          return false;
        }

        first = false;

        token = token.substring(beforeStartIdx + beforeWildcard.length());
        wildcardIndex = afterWildcard.indexOf("*");
      }
      return token.endsWith(afterWildcard);
    }

    // no wildcard -> token must be equal.
    return token.equals(tlsDnsToken);
  }

  private static String extractCommonName(String principal) throws SSLException {
    if (principal == null) {
      return null;
    }
    try {
      LdapName ldapName = new LdapName(principal);

      for (Rdn rdn : ldapName.getRdns()) {
        if (rdn.getType().equalsIgnoreCase("CN")) {
          Object obj = rdn.getValue();
          if (obj != null) {
            return obj.toString();
          }
        }
      }
      return null;
    } catch (InvalidNameException e) {
      throw new SSLException("DN value \"" + principal + "\" is invalid");
    }
  }

  private static String normaliseAddress(String hostname) {
    try {
      if (hostname == null) {
        return null;
      }
      InetAddress inetAddress = InetAddress.getByName(hostname);
      return inetAddress.getHostAddress();
    } catch (UnknownHostException unexpected) {
      return hostname;
    }
  }

  private static String normalizedHostMsg(String normalizedHost) {
    StringBuilder msg = new StringBuilder();
    if (isIPv4(normalizedHost)) {
      msg.append("IPv4 host \"");
    } else if (isIPv6(normalizedHost)) {
      msg.append("IPv6 host \"");
    } else {
      msg.append("DNS host \"");
    }
    msg.append(normalizedHost).append("\"");
    return msg.toString();
  }

  /**
   * check if ip correspond to IPV4
   *
   * @param ip ip value
   * @return if ip is using IPV4 format
   */
  public static boolean isIPv4(final String ip) {
    return IP_V4.matcher(ip).matches();
  }

  /**
   * check if ip correspond to IPV6
   *
   * @param ip ip value
   * @return if ip is using IPV6 format
   */
  public static boolean isIPv6(final String ip) {
    return IP_V6.matcher(ip).matches() || IP_V6_COMPRESSED.matcher(ip).matches();
  }

  private static SubjectAltNames getSubjectAltNames(X509Certificate cert)
      throws CertificateParsingException {
    Collection<List<?>> entries = cert.getSubjectAlternativeNames();
    SubjectAltNames subjectAltNames = new SubjectAltNames();
    if (entries != null) {
      for (List<?> entry : entries) {
        if (entry.size() >= 2) {
          int type = (Integer) entry.get(0);

          if (type == 2) { // DNS
            String altNameDns = (String) entry.get(1);
            if (altNameDns != null) {
              String normalizedSubjectAlt = altNameDns.toLowerCase(Locale.ROOT);
              subjectAltNames.add(new GeneralName(normalizedSubjectAlt, Extension.DNS));
            }
          }

          if (type == 7) { // IP
            String altNameIp = (String) entry.get(1);
            if (altNameIp != null) {
              subjectAltNames.add(new GeneralName(altNameIp, Extension.IP));
            }
          }
        }
      }
    }
    return subjectAltNames;
  }

  /**
   * Verification that throw an exception with a detailed error message in case of error.
   *
   * @param host hostname
   * @param cert certificate
   * @param serverThreadId server thread Identifier to identify connection in logs
   * @throws SSLException exception
   */
  public static void verify(String host, X509Certificate cert, long serverThreadId)
      throws SSLException {
    if (host == null) {
      return; // no validation if no host (possible for name pipe)
    }
    String lowerCaseHost = host.toLowerCase(Locale.ROOT);
    try {
      // ***********************************************************
      // RFC 6125 : check Subject Alternative Name (SAN)
      // ***********************************************************
      SubjectAltNames subjectAltNames = getSubjectAltNames(cert);
      if (!subjectAltNames.isEmpty()) {

        // ***********************************************************
        // Host is IPv4 : Check corresponding entries in subject alternative names
        // ***********************************************************
        if (isIPv4(lowerCaseHost)) {
          for (GeneralName entry : subjectAltNames.getGeneralNames()) {
            if (logger.isTraceEnabled()) {
              logger.trace(
                  "Conn={}. IPv4 verification of hostname : type={} value={} to {}",
                  serverThreadId,
                  entry.extension,
                  entry.value,
                  lowerCaseHost);
            }

            if (entry.extension == Extension.IP && lowerCaseHost.equals(entry.value)) {
              return;
            }
          }
        } else if (isIPv6(lowerCaseHost)) {
          // ***********************************************************
          // Host is IPv6 : Check corresponding entries in subject alternative names
          // ***********************************************************
          String normalisedHost = normaliseAddress(lowerCaseHost);
          for (GeneralName entry : subjectAltNames.getGeneralNames()) {
            if (logger.isTraceEnabled()) {
              logger.trace(
                  "Conn={}. IPv6 verification of hostname : type={} value={} to {}",
                  serverThreadId,
                  entry.extension,
                  entry.value,
                  lowerCaseHost);
            }

            if (entry.extension == Extension.IP
                && !isIPv4(entry.value)
                && normalisedHost.equals(normaliseAddress(entry.value))) {
              return;
            }
          }
        } else {
          // ***********************************************************
          // Host is not IP = DNS : Check corresponding entries in alternative subject names
          // ***********************************************************
          for (GeneralName entry : subjectAltNames.getGeneralNames()) {
            if (logger.isTraceEnabled()) {
              logger.trace(
                  "Conn={}. DNS verification of hostname : type={} value={} to {}",
                  serverThreadId,
                  entry.extension,
                  entry.value,
                  lowerCaseHost);
            }

            if (entry.extension == Extension.DNS
                && matchDns(lowerCaseHost, entry.value.toLowerCase(Locale.ROOT))) {
              return;
            }
          }
        }
      }

      // ***********************************************************
      // RFC 2818 : legacy fallback using CN (recommendation is using alt-names)
      // ***********************************************************
      X500Principal subjectPrincipal = cert.getSubjectX500Principal();
      String cn = extractCommonName(subjectPrincipal.getName(X500Principal.RFC2253));

      if (cn == null) {
        if (subjectAltNames.isEmpty()) {
          throw new SSLException(
              "CN not found in certificate principal \""
                  + subjectPrincipal
                  + "\" and certificate doesn't contain SAN");
        } else {
          throw new SSLException(
              "CN not found in certificate principal \""
                  + subjectPrincipal
                  + "\" and "
                  + normalizedHostMsg(lowerCaseHost)
                  + " doesn't correspond to "
                  + subjectAltNames);
        }
      }

      String normalizedCn = cn.toLowerCase(Locale.ROOT);
      if (logger.isTraceEnabled()) {
        logger.trace(
            "Conn={}. DNS verification of hostname : CN={} to {}",
            serverThreadId,
            normalizedCn,
            lowerCaseHost);
      }
      if (!matchDns(lowerCaseHost, normalizedCn)) {
        String errorMsg =
            normalizedHostMsg(lowerCaseHost)
                + " doesn't correspond to certificate CN \""
                + normalizedCn
                + "\"";
        if (!subjectAltNames.isEmpty()) {
          errorMsg += " and " + subjectAltNames;
        }
        throw new SSLException(errorMsg);
      }

    } catch (CertificateParsingException cpe) {
      throw new SSLException("certificate parsing error : " + cpe.getMessage());
    }
  }

  private enum Extension {
    DNS,
    IP
  }

  private static class GeneralName {

    private final String value;
    private final Extension extension;

    public GeneralName(String value, Extension extension) {
      this.value = value;
      this.extension = extension;
    }

    @Override
    public String toString() {
      return "{" + extension + ":\"" + value + "\"}";
    }
  }

  private static class SubjectAltNames {

    private final List<GeneralName> generalNames = new ArrayList<>();

    @Override
    public String toString() {
      StringBuilder sb = new StringBuilder("SAN[");
      boolean first = true;

      for (GeneralName generalName : generalNames) {
        if (!first) {
          sb.append(",");
        }
        first = false;
        sb.append(generalName.toString());
      }
      sb.append("]");
      return sb.toString();
    }

    public List<GeneralName> getGeneralNames() {
      return generalNames;
    }

    public void add(GeneralName generalName) {
      generalNames.add(generalName);
    }

    public boolean isEmpty() {
      return generalNames.isEmpty();
    }
  }
}
