/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.solr.util;

import com.carrotsearch.randomizedtesting.RandomizedTest;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.SecureRandomParameters;
import java.security.SecureRandomSpi;
import java.security.UnrecoverableKeyException;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.TrustSelfSignedStrategy;
import org.apache.http.ssl.SSLContextBuilder;
import org.apache.http.ssl.SSLContexts;
import org.apache.lucene.util.Constants;
import org.apache.solr.client.solrj.embedded.SSLConfig;
import org.apache.solr.client.solrj.impl.HttpClientUtil;
import org.apache.solr.client.solrj.impl.HttpClientUtil.SocketFactoryRegistryProvider;
import org.eclipse.jetty.util.resource.Resource;
import org.eclipse.jetty.util.security.CertificateUtils;
import org.eclipse.jetty.util.ssl.SslContextFactory;

/**
 * An SSLConfig that provides {@link SSLConfig} and {@link SocketFactoryRegistryProvider} for both
 * clients and servers that supports reading key/trust store information directly from resource
 * files provided with the Solr test-framework classes
 */
public class SSLTestConfig {
  private static final String TEST_KEYSTORE_BOGUSHOST_RESOURCE =
      "SSLTestConfig.hostname-and-ip-missmatch.keystore";
  private static final String TEST_KEYSTORE_LOCALHOST_RESOURCE = "SSLTestConfig.testing.keystore";
  private static final String TEST_PASSWORD = "secret";

  private final boolean checkPeerName;
  private final Resource keyStore;
  private final Resource trustStore;
  private final boolean useSsl;
  private final boolean clientAuth;

  /** Creates an SSLTestConfig that does not use SSL or client authentication */
  public SSLTestConfig() {
    this(false, false);
  }

  /**
   * Create an SSLTestConfig based on a few caller specified options, implicitly assuming <code>
   * checkPeerName=false</code>.
   *
   * <p>As needed, keystore/truststore information will be pulled from a hardcoded resource file
   * provided by the solr test-framework
   *
   * @param useSsl - whether SSL should be required.
   * @param clientAuth - whether client authentication should be required.
   */
  public SSLTestConfig(boolean useSsl, boolean clientAuth) {
    this(useSsl, clientAuth, false);
  }

  // NOTE: if any javadocs below change, update create-keystores.sh
  /**
   * Create an SSLTestConfig based on a few caller specified options. As needed, keystore/truststore
   * information will be pulled from a hardcoded resource files provided by the solr test-framework
   * based on the value of <code>checkPeerName</code>:
   *
   * <ul>
   *   <li><code>true</code> - A keystore resource file will be used that specifies a CN of <code>
   *       localhost</code> and a SAN IP of <code>127.0.0.1</code>, to ensure that all connections
   *       should be valid regardless of what machine runs the tests.
   *   <li><code>false</code> - A keystore resource file will be used that specifies a bogus
   *       hostname in the CN and reserved IP as the SAN, since no (valid) tests using this
   *       SSLTestConfig should care what CN/SAN are.
   * </ul>
   *
   * @param useSsl - whether SSL should be required.
   * @param clientAuth - whether client authentication should be required.
   * @param checkPeerName - whether the client should validate the 'peer name' of the SSL
   *     Certificate (and which testing Cert should be used)
   * @see HttpClientUtil#SYS_PROP_CHECK_PEER_NAME
   */
  public SSLTestConfig(boolean useSsl, boolean clientAuth, boolean checkPeerName) {
    this.useSsl = useSsl;
    this.clientAuth = clientAuth;
    this.checkPeerName = checkPeerName;

    if (this.useSsl) {
      assumeSslIsSafeToTest();
    }

    final String resourceName =
        checkPeerName ? TEST_KEYSTORE_LOCALHOST_RESOURCE : TEST_KEYSTORE_BOGUSHOST_RESOURCE;
    trustStore = keyStore = Resource.newClassPathResource(resourceName);
    if (null == keyStore || !keyStore.exists()) {
      throw new IllegalStateException(
          "Unable to locate keystore resource file in classpath: " + resourceName);
    }
  }

  /** If true, then servers hostname/ip should be validated against the SSL Cert metadata */
  public boolean getCheckPeerName() {
    return checkPeerName;
  }

  /** All other settings on this object are ignored unless this is true */
  public boolean isSSLMode() {
    return useSsl;
  }

  public boolean isClientAuthMode() {
    return clientAuth;
  }

  /**
   * Creates a {@link SocketFactoryRegistryProvider} for HTTP <b>clients</b> to use when
   * communicating with servers which have been configured based on the settings of this object.
   * When {@link #isSSLMode} is true, this <code>SocketFactoryRegistryProvider</code> will
   * <i>only</i> support HTTPS (no HTTP scheme) using the appropriate certs. When {@link #isSSLMode}
   * is false, <i>only</i> HTTP (no HTTPS scheme) will be supported.
   */
  public SocketFactoryRegistryProvider buildClientSocketFactoryRegistryProvider() {
    if (isSSLMode()) {
      SSLConnectionSocketFactory sslConnectionFactory = buildClientSSLConnectionSocketFactory();
      assert null != sslConnectionFactory;
      return new SSLSocketFactoryRegistryProvider(sslConnectionFactory);
    } else {
      return HTTP_ONLY_SCHEMA_PROVIDER;
    }
  }

  /**
   * Builds a new SSLContext for HTTP <b>clients</b> to use when communicating with servers which
   * have been configured based on the settings of this object.
   *
   * <p>NOTE: Uses a completely insecure {@link SecureRandom} instance to prevent tests from
   * blocking due to lack of entropy, also explicitly allows the use of self-signed certificates
   * (since that's what is almost always used during testing).
   */
  public SSLContext buildClientSSLContext()
      throws KeyManagementException, UnrecoverableKeyException, NoSuchAlgorithmException,
          KeyStoreException {

    assert isSSLMode();

    SSLContextBuilder builder = SSLContexts.custom();
    builder.setSecureRandom(NotSecurePseudoRandom.INSTANCE);

    // NOTE: KeyStore & TrustStore are swapped because they are from configured from server
    // perspective...
    // we are a client - our keystore contains the keys the server trusts, and vice versa
    builder
        .loadTrustMaterial(buildKeyStore(keyStore, TEST_PASSWORD), new TrustSelfSignedStrategy())
        .build();

    if (isClientAuthMode()) {
      builder.loadKeyMaterial(
          buildKeyStore(trustStore, TEST_PASSWORD), TEST_PASSWORD.toCharArray());
    }

    return builder.build();
  }

  public SSLConfig buildClientSSLConfig() {
    if (!isSSLMode()) {
      return null;
    }

    return new SSLConfig(isSSLMode(), isClientAuthMode(), null, null, null, null) {
      @Override
      public SslContextFactory.Client createClientContextFactory() {
        SslContextFactory.Client factory = new SslContextFactory.Client(!checkPeerName);
        try {
          factory.setSslContext(buildClientSSLContext());
        } catch (KeyManagementException
            | UnrecoverableKeyException
            | NoSuchAlgorithmException
            | KeyStoreException e) {
          throw new IllegalStateException(
              "Unable to setup https scheme for HTTPClient to test SSL.", e);
        }
        return factory;
      }
    };
  }

  /**
   * Builds a new SSLContext for jetty servers which have been configured based on the settings of
   * this object.
   *
   * <p>NOTE: Uses a completely insecure {@link SecureRandom} instance to prevent tests from
   * blocking due to lack of entropy, also explicitly allows the use of self-signed certificates
   * (since that's what is almost always used during testing). almost always used during testing).
   */
  public SSLConfig buildServerSSLConfig() {
    if (!isSSLMode()) {
      return null;
    }

    return new SSLConfig(isSSLMode(), isClientAuthMode(), null, null, null, null) {
      @Override
      public SslContextFactory.Server createContextFactory() {
        SslContextFactory.Server factory = new SslContextFactory.Server();
        try {
          SSLContextBuilder builder = SSLContexts.custom();
          builder.setSecureRandom(NotSecurePseudoRandom.INSTANCE);

          builder.loadKeyMaterial(
              buildKeyStore(keyStore, TEST_PASSWORD), TEST_PASSWORD.toCharArray());

          if (isClientAuthMode()) {
            builder
                .loadTrustMaterial(
                    buildKeyStore(trustStore, TEST_PASSWORD), new TrustSelfSignedStrategy())
                .build();
          }
          factory.setSslContext(builder.build());
        } catch (Exception e) {
          throw new RuntimeException("ssl context init failure: " + e.getMessage(), e);
        }
        factory.setNeedClientAuth(isClientAuthMode());
        return factory;
      }
    };
  }

  /** Constructs a KeyStore using the specified filename and password */
  private static KeyStore buildKeyStore(Resource resource, String password) {
    try {
      return CertificateUtils.getKeyStore(resource, "JKS", null, password);
    } catch (Exception ex) {
      throw new IllegalStateException(
          "Unable to build KeyStore from resource: " + resource.getName(), ex);
    }
  }

  /**
   * Constructs a new SSLConnectionSocketFactory for HTTP <b>clients</b> to use when communicating
   * with servers which have been configured based on the settings of this object. Will return null
   * unless {@link #isSSLMode} is true.
   */
  public SSLConnectionSocketFactory buildClientSSLConnectionSocketFactory() {
    if (!isSSLMode()) {
      return null;
    }
    SSLConnectionSocketFactory sslConnectionFactory;
    try {
      SSLContext sslContext = buildClientSSLContext();
      if (checkPeerName == false) {
        sslConnectionFactory =
            new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
      } else {
        sslConnectionFactory = new SSLConnectionSocketFactory(sslContext);
      }
    } catch (KeyManagementException
        | UnrecoverableKeyException
        | NoSuchAlgorithmException
        | KeyStoreException e) {
      throw new IllegalStateException(
          "Unable to setup https scheme for HTTPClient to test SSL.", e);
    }
    return sslConnectionFactory;
  }

  /**
   * A SocketFactoryRegistryProvider that only knows about SSL using a specified
   * SSLConnectionSocketFactory
   */
  private static class SSLSocketFactoryRegistryProvider extends SocketFactoryRegistryProvider {
    private final SSLConnectionSocketFactory sslConnectionFactory;

    public SSLSocketFactoryRegistryProvider(SSLConnectionSocketFactory sslConnectionFactory) {
      this.sslConnectionFactory = sslConnectionFactory;
    }

    @Override
    public Registry<ConnectionSocketFactory> getSocketFactoryRegistry() {
      return RegistryBuilder.<ConnectionSocketFactory>create()
          .register("https", sslConnectionFactory)
          .build();
    }
  }

  /** A SocketFactoryRegistryProvider that only knows about HTTP */
  private static final SocketFactoryRegistryProvider HTTP_ONLY_SCHEMA_PROVIDER =
      new SocketFactoryRegistryProvider() {
        @Override
        public Registry<ConnectionSocketFactory> getSocketFactoryRegistry() {
          return RegistryBuilder.<ConnectionSocketFactory>create()
              .register("http", PlainConnectionSocketFactory.getSocketFactory())
              .build();
        }
      };

  /**
   * A mocked up instance of SecureRandom that just uses {@link Random} under the covers. This is to
   * prevent blocking issues that arise in platform default SecureRandom instances due to too many
   * instances / not enough random entropy. Tests do not need secure SSL.
   */
  private static class NotSecurePseudoRandom extends SecureRandom {
    public static final SecureRandom INSTANCE = new NotSecurePseudoRandom();
    /**
     * Helper method that can be used to fill an array with non-zero data. (Attempted workarround of
     * Solaris SSL Padding bug: SOLR-9068)
     */
    private static final byte[] fillData(byte[] data) {
      ThreadLocalRandom.current().nextBytes(data);
      return data;
    }

    /** SPI Used to init all instances */
    private static final SecureRandomSpi NOT_SECURE_SPI =
        new SecureRandomSpi() {
          /** returns a new byte[] filled with static data */
          @Override
          public byte[] engineGenerateSeed(int numBytes) {
            return fillData(new byte[numBytes]);
          }
          /** fills the byte[] with static data */
          @Override
          public void engineNextBytes(byte[] bytes) {
            fillData(bytes);
          }
          /** NOOP */
          @Override
          public void engineSetSeed(byte[] seed) {
            /* NOOP */
          }
        };

    private NotSecurePseudoRandom() {
      super(NOT_SECURE_SPI, null);
    }

    /** returns a new byte[] filled with static data */
    @Override
    public byte[] generateSeed(int numBytes) {
      return fillData(new byte[numBytes]);
    }
    /** fills the byte[] with static data */
    @Override
    public void nextBytes(byte[] bytes) {
      fillData(bytes);
    }

    @Override
    public void nextBytes(byte[] bytes, SecureRandomParameters params) {
      fillData(bytes);
    }
    /** NOOP */
    @Override
    public void setSeed(byte[] seed) {
      /* NOOP */
    }
    /** NOOP */
    @Override
    public void setSeed(long seed) {
      /* NOOP */
    }

    @Override
    public void reseed() {
      /* NOOP */
    }

    @Override
    public void reseed(SecureRandomParameters params) {
      /* NOOP */
    }
  }

  /**
   * Helper method for sanity checking if it's safe to use SSL on this JVM
   *
   * @see <a href="https://issues.apache.org/jira/browse/SOLR-12988">SOLR-12988</a>
   * @throws org.junit.internal.AssumptionViolatedException if this JVM is known to have SSL
   *     problems
   */
  public static void assumeSslIsSafeToTest() {
    if (Constants.JVM_NAME.startsWith("OpenJDK")
        || Constants.JVM_NAME.startsWith("Java HotSpot(TM)")) {
      RandomizedTest.assumeFalse(
          "Test (or randomization for this seed) wants to use SSL, "
              + "but SSL is known to fail on your JVM: "
              + Constants.JVM_NAME
              + " / "
              + Constants.JVM_VERSION,
          isOpenJdkJvmVersionKnownToHaveProblems(Constants.JVM_VERSION));
    }
  }

  /**
   * package visibility for tests
   *
   * @see Constants#JVM_VERSION
   * @lucene.internal
   */
  static boolean isOpenJdkJvmVersionKnownToHaveProblems(final String jvmVersion) {
    // TODO: would be nice to replace with Runtime.Version once we don't have to
    // worry about java8 support when backporting to branch_8x
    return KNOWN_BAD_OPENJDK_JVMS.matcher(jvmVersion).matches();
  }

  private static final Pattern KNOWN_BAD_OPENJDK_JVMS =
      Pattern.compile( // 11 to 11.0.2 were all definitely problematic
          // - https://bugs.openjdk.java.net/browse/JDK-8212885
          // - https://bugs.openjdk.java.net/browse/JDK-8213202
          "(^11(\\.0(\\.0|\\.1|\\.2)?)?($|(\\_|\\+|\\-).*$))|"
              +
              // early (pre-ea) "testing" builds of 11, 12, and 13 were also buggy
              // - https://bugs.openjdk.java.net/browse/JDK-8224829
              "(^(11|12|13).*-testing.*$)|"
              +
              // So far, all 13-ea builds (up to 13-ea-26) have been buggy
              // - https://bugs.openjdk.java.net/browse/JDK-8226338
              "(^13-ea.*$)");
}
