SslOptions.java

package redis.clients.jedis;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URL;
import java.util.Arrays;
import java.util.Objects;

import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;

import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509ExtendedTrustManager;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Options to configure SSL options for the connections kept to Redis servers.
 *
 * @author Mark Paluch
 */
public class SslOptions {

    private static final Logger logger = LoggerFactory.getLogger(SslOptions.class);

    private final String keyManagerAlgorithm = KeyManagerFactory.getDefaultAlgorithm();

    private final String trustManagerAlgorithm = TrustManagerFactory.getDefaultAlgorithm();

    private final String keyStoreType;

    private final String trustStoreType;

    private final Resource keystoreResource;

    private final char[] keystorePassword;

    private final Resource truststoreResource;

    private final char[] truststorePassword;

    private final SSLParameters sslParameters;

    private final SslVerifyMode sslVerifyMode;

    private final String sslProtocol; // protocol for SSLContext

    private SslOptions(Builder builder) {
        this.keyStoreType = builder.keyStoreType;
        this.trustStoreType = builder.trustStoreType;
        this.keystoreResource = builder.keystoreResource;
        this.keystorePassword = builder.keystorePassword;
        this.truststoreResource = builder.truststoreResource;
        this.truststorePassword = builder.truststorePassword;
        this.sslParameters = builder.sslParameters;
        this.sslVerifyMode = builder.sslVerifyMode;
        this.sslProtocol = builder.sslProtocol;
    }

    /**
     * Returns a new {@link SslOptions.Builder} to construct {@link SslOptions}.
     *
     * @return a new {@link SslOptions.Builder} to construct {@link SslOptions}.
     */
    public static SslOptions.Builder builder() {
        return new SslOptions.Builder();
    }

    /**
     * Builder for {@link SslOptions}.
     */
    public static class Builder {

        private String keyStoreType;

        private String trustStoreType;

        private Resource keystoreResource;

        private char[] keystorePassword = null;

        private Resource truststoreResource;

        private char[] truststorePassword = null;

        private SSLParameters sslParameters;

        private SslVerifyMode sslVerifyMode = SslVerifyMode.FULL;

        private String sslProtocol = "TLS"; // protocol for SSLContext

        private Builder() {
        }

        /**
         * Sets the KeyStore type. Defaults to {@link KeyStore#getDefaultType()} if not set.
         *
         * @param keyStoreType the keystore type to use, must not be {@code null}.
         * @return {@code this}
         */
        public Builder keyStoreType(String keyStoreType) {
            this.keyStoreType = Objects.requireNonNull(keyStoreType, "KeyStoreType must not be null");
            return this;
        }

        /**
         * Sets the KeyStore type. Defaults to {@link KeyStore#getDefaultType()} if not set.
         *
         * @param trustStoreType the keystore type to use, must not be {@code null}.
         * @return {@code this}
         */
        public Builder trustStoreType(String trustStoreType) {
            this.trustStoreType = Objects.requireNonNull(trustStoreType, "TrustStoreType must not be null");
            return this;
        }

        /**
         * Sets the Keystore file to load client certificates. The key store file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The keystore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param keystore the keystore file, must not be {@code null}.
         * @return {@code this}
         */
        public Builder keystore(File keystore) {
            return keystore(keystore, null);
        }

        /**
         * Sets the Keystore file to load client certificates. The keystore file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The keystore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param keystore the keystore file, must not be {@code null}.
         * @param keystorePassword the keystore password. May be empty to omit password and the keystore integrity check.
         * @return {@code this}
         */
        public Builder keystore(File keystore, char[] keystorePassword) {

            Objects.requireNonNull(keystore, "Keystore must not be null");
            assertFile("Keystore", keystore);

            return keystore(Resource.from(keystore), keystorePassword);
        }

        /**
         * Sets the Keystore resource to load client certificates. The keystore file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The keystore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param keystore the keystore URL, must not be {@code null}.
         * @return {@code this}
         */
        public Builder keystore(URL keystore) {
            return keystore(keystore, null);
        }

        /**
         * Sets the Keystore resource to load client certificates. The keystore file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The keystore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param keystore the keystore file, must not be {@code null}.
         * @param keystorePassword
         * @return {@code this}
         */
        public Builder keystore(URL keystore, char[] keystorePassword) {

            Objects.requireNonNull(keystore, "Keystore must not be null");

            return keystore(Resource.from(keystore), keystorePassword);
        }

        /**
         * Sets the Java Keystore resource to load client certificates. The keystore file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The keystore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param resource the provider that opens a {@link InputStream} to the keystore file, must not be {@code null}.
         * @param keystorePassword the keystore password. May be empty to omit password and the keystore integrity check.
         * @return {@code this}
         */
        public Builder keystore(Resource resource, char[] keystorePassword) {

            this.keystoreResource = Objects.requireNonNull(resource, "Keystore InputStreamProvider must not be null");

            this.keystorePassword = getPassword(keystorePassword);

            return this;
        }

        /**
         * Sets the Truststore file to load trusted certificates. The truststore file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The truststore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param truststore the truststore file, must not be {@code null}.
         * @return {@code this}
         */
        public Builder truststore(File truststore) {
            return truststore(truststore, null);
        }

        /**
         * Sets the Truststore file to load trusted certificates. The truststore file must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The truststore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param truststore the truststore file, must not be {@code null}.
         * @param truststorePassword the truststore password. May be empty to omit password and the truststore integrity check.
         * @return {@code this}
         */
        public Builder truststore(File truststore, char[] truststorePassword) {

            Objects.requireNonNull(truststore, "Truststore must not be null");
            assertFile("Truststore", truststore);

            return truststore(Resource.from(truststore), truststorePassword);
        }

        /**
         * Sets the Truststore resource to load trusted certificates. The truststore resource must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The truststore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param truststore the truststore file, must not be {@code null}.
         * @return {@code this}
         */
        public Builder truststore(URL truststore) {
            return truststore(truststore, null);
        }

        /**
         * Sets the Truststore resource to load trusted certificates. The truststore resource must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The truststore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param truststore the truststore file, must not be {@code null}.
         * @param truststorePassword the truststore password. May be empty to omit password and the truststore integrity check.
         * @return {@code this}
         */
        public Builder truststore(URL truststore, char[] truststorePassword) {

            Objects.requireNonNull(truststore, "Truststore must not be null");

            return truststore(Resource.from(truststore), truststorePassword);
        }

        /**
         * Sets the Truststore resource to load trusted certificates. The truststore resource must be supported by
         * {@link java.security.KeyStore} which is {@link KeyStore#getDefaultType()} by default. The truststore is reloaded on
         * each connection attempt that allows to replace certificates during runtime.
         *
         * @param resource the provider that opens a {@link InputStream} to the keystore file, must not be {@code null}.
         * @param truststorePassword the truststore password. May be empty to omit password and the truststore integrity check.
         * @return {@code this}
         */
        public Builder truststore(Resource resource, char[] truststorePassword) {

            this.truststoreResource = Objects.requireNonNull(resource, "Truststore InputStreamProvider must not be null");

            this.truststorePassword = getPassword(truststorePassword);

            return this;
        }

        /**
         * Sets a configured {@link SSLParameters}.
         *
         * @param sslParameters a {@link SSLParameters} object.
         * @return {@code this}
         */
        public Builder sslParameters(SSLParameters sslParameters) {
            this.sslParameters = sslParameters;
            return this;
        }

        /**
         * Sets the {@link SslVerifyMode}.
         *
         * @param sslVerifyMode the {@link SslVerifyMode}.
         * @return {@code this}
         */
        public Builder sslVerifyMode(SslVerifyMode sslVerifyMode) {
            this.sslVerifyMode = sslVerifyMode;
            return this;
        }

        /**
         * The SSL/TLS protocol to be used to initialize {@link SSLContext}.
         * @param protocol the ssl/tls protocol
         * @return {@code this}
         */
        public Builder sslProtocol(String protocol) {
            this.sslProtocol = protocol;
            return this;
        }

        /**
         * Create a new instance of {@link SslOptions}
         *
         * @return new instance of {@link SslOptions}
         */
        public SslOptions build() {
            if (this.sslParameters == null) {
                this.sslParameters = new SSLParameters();
            }
            return new SslOptions(this);
        }
    }

    /**
     * A {@link SSLContext} object that is configured with values from this {@link SslOptions} object.
     *
     * @return {@link SSLContext}
     * @throws IOException thrown when loading the keystore or the truststore fails.
     * @throws GeneralSecurityException thrown when loading the keystore or the truststore fails.
     */
    public SSLContext createSslContext() throws IOException, GeneralSecurityException {

        KeyManager[] keyManagers = null;
        TrustManager[] trustManagers = null;

        if (sslVerifyMode == SslVerifyMode.FULL) {
            this.sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
        } else if (sslVerifyMode == SslVerifyMode.CA) {
            this.sslParameters.setEndpointIdentificationAlgorithm("");
        } else if (sslVerifyMode == SslVerifyMode.INSECURE) {
            trustManagers = new TrustManager[] { INSECURE_TRUST_MANAGER };
        }

        if (keystoreResource != null) {

            KeyStore keyStore = KeyStore.getInstance(keyStoreType);
            try (InputStream keystoreStream = keystoreResource.get()) {
                keyStore.load(keystoreStream, keystorePassword);
            }

            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(keyManagerAlgorithm);
            keyManagerFactory.init(keyStore, keystorePassword);
            keyManagers = keyManagerFactory.getKeyManagers();
        }

        if (trustManagers == null && truststoreResource != null) {

            KeyStore trustStore = KeyStore.getInstance(trustStoreType);
            try (InputStream truststoreStream = truststoreResource.get()) {
                trustStore.load(truststoreStream, truststorePassword);
            }

            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(trustManagerAlgorithm);
            trustManagerFactory.init(trustStore);
            trustManagers = trustManagerFactory.getTrustManagers();
        }

        SSLContext sslContext = SSLContext.getInstance(sslProtocol);
        sslContext.init(keyManagers, trustManagers, null);

        return sslContext;
    }

    /**
     * {@link #createSslContext()} must be called before this.
     * @return {@link SSLParameters}
     */
    public SSLParameters getSslParameters() {
        return sslParameters;
    }

    private static char[] getPassword(char[] chars) {
        return chars != null ? Arrays.copyOf(chars, chars.length) : null;
    }

    /**
     * Assert that {@code file} {@link File#exists() exists}.
     *
     * @param keyword file recognizer
     * @param file
     * @throws IllegalArgumentException if the file doesn't exist
     */
    public static void assertFile(String keyword, File file) {
        if (!file.exists()) {
            throw new IllegalArgumentException(String.format("%s file %s does not exist", keyword, file));
        }
        if (!file.isFile()) {
            throw new IllegalArgumentException(String.format("%s file %s is not a file", keyword, file));
        }
    }

    /**
     * Supplier for a {@link InputStream} representing a resource. The resulting {@link InputStream} must be closed by
     * the calling code.
     */
    @FunctionalInterface
    public interface Resource {

        /**
         * Create a {@link Resource} that obtains a {@link InputStream} from a {@link URL}.
         *
         * @param url the URL to obtain the {@link InputStream} from.
         * @return a {@link Resource} that opens a connection to the URL and obtains the {@link InputStream} for it.
         */
        static Resource from(URL url) {

            Objects.requireNonNull(url, "URL must not be null");

            return () -> url.openConnection().getInputStream();
        }

        /**
         * Create a {@link Resource} that obtains a {@link InputStream} from a {@link File}.
         *
         * @param file the File to obtain the {@link InputStream} from.
         * @return a {@link Resource} that obtains the {@link FileInputStream} for the given {@link File}.
         */
        static Resource from(File file) {

            Objects.requireNonNull(file, "File must not be null");

            return () -> new FileInputStream(file);
        }

        /**
         * Obtains the {@link InputStream}.
         *
         * @return the {@link InputStream}.
         * @throws IOException
         */
        InputStream get() throws IOException;

    }

    private static final X509Certificate[] EMPTY_X509_CERTIFICATES = {};

    private static final TrustManager INSECURE_TRUST_MANAGER = new X509ExtendedTrustManager() {

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String s) {
            if (logger.isDebugEnabled()) {
                logger.debug("Accepting a client certificate: " + chain[0].getSubjectDN());
            }
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String s) {
            if (logger.isDebugEnabled()) {
                logger.debug("Accepting a server certificate: " + chain[0].getSubjectDN());
            }
        }

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String s, Socket socket)
                throws CertificateException {
            checkClientTrusted(chain, s);
        }

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String s, SSLEngine sslEngine)
                throws CertificateException {
            checkClientTrusted(chain, s);
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String s, Socket socket)
                throws CertificateException {
            checkServerTrusted(chain, s);
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String s, SSLEngine sslEngine)
                throws CertificateException {
            checkServerTrusted(chain, s);
        }

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return EMPTY_X509_CERTIFICATES;
        }
    };

}