From d0673b0cfb96df75d6a8772d4ad53ffec5510ded Mon Sep 17 00:00:00 2001 From: Alexander Reelsen Date: Thu, 17 Jul 2014 08:32:59 +0200 Subject: [PATCH] Added SSL support in netty This introduces the possibility to have all communications (transport and HTTP) to run over SSL. Original commit: elastic/x-pack-elasticsearch@c816a65f53817e1e1207e63cfd207d9a8c78eaa0 --- README.asciidoc | 34 ++- pom.xml | 7 + .../elasticsearch/shield/SecurityModule.java | 5 + .../shield/ssl/ElasticsearchSSLException.java | 29 ++ .../elasticsearch/shield/ssl/SSLConfig.java | 106 ++++++++ .../netty/NettySSLHttpServerTransport.java | 58 ++++ .../NettySSLHttpServerTransportModule.java | 22 ++ .../shield/ssl/netty/NettySSLTransport.java | 94 +++++++ .../ssl/netty/NettySSLTransportModule.java | 28 ++ .../netty/SecureMessageChannelHandler.java | 43 +++ .../shield/plugin/ShieldPluginTests.java | 6 +- .../shield/ssl/SslIntegrationTests.java | 250 ++++++++++++++++++ .../resources/certs/simple/testclient.cert | 17 ++ .../resources/certs/simple/testclient.jks | Bin 0 -> 3032 bytes src/test/resources/certs/simple/testnode.cert | 17 ++ src/test/resources/certs/simple/testnode.jks | Bin 0 -> 3030 bytes tests.policy | 3 + 17 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/elasticsearch/shield/ssl/ElasticsearchSSLException.java create mode 100644 src/main/java/org/elasticsearch/shield/ssl/SSLConfig.java create mode 100644 src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransport.java create mode 100644 src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransportModule.java create mode 100644 src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransport.java create mode 100644 src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransportModule.java create mode 100644 src/main/java/org/elasticsearch/shield/ssl/netty/SecureMessageChannelHandler.java create mode 100644 src/test/java/org/elasticsearch/shield/ssl/SslIntegrationTests.java create mode 100644 src/test/resources/certs/simple/testclient.cert create mode 100644 src/test/resources/certs/simple/testclient.jks create mode 100644 src/test/resources/certs/simple/testnode.cert create mode 100644 src/test/resources/certs/simple/testnode.jks diff --git a/README.asciidoc b/README.asciidoc index d8515ed4875..f97ac6a1aea 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -4,5 +4,37 @@ This plugins adds security features to elasticsearch == Access control -== Encrypted communication +== Encrypted communication using TLS/SSL + +=== Configuration parameters + +==== Transport protocol + +* `transport.tcp.ssl`: true|false (defaults to true) +* `transport.tcp.ssl.keystore`: /path/to/the/keystore (absolute path to the keystore, which contains private keys) +* `transport.tcp.ssl.keystore_password`: password of the keystore +* `transport.tcp.ssl.keystore_algorithm`: keystore format (defaults to SunX509) +* `transport.tcp.ssl.truststore`: /path/to/the/truststore (absolute path to the truststore, which contains trusted keys) +* `transport.tcp.ssl.truststore_password`: password of the truststore +* `transport.tcp.ssl.truststore_algorithm`: truststore format (defaults to SunX509) +* `transport.tcp.ssl.client.auth`: true|false (defaults to true) +* `transport.tcp.ssl.ciphers`: Supported ciphers, defaults to `TLS_RSA_WITH_AES_128_CBC_SHA256` and `TLS_RSA_WITH_AES_128_CBC_SHA` + +==== HTTP + +* `http.ssl`: true|false (defaults to true) +* `http.ssl.keystore`: /path/to/the/keystore (absolute path to the keystore, which contains private keys) +* `http.ssl.keystore_password`: password of the keystore +* `http.ssl.keystore_algorithm`: keystore format (defaults to SunX509) +* `http.ssl.truststore`: /path/to/the/truststore (absolute path to the truststore, which contains trusted keys) +* `http.ssl.truststore_password`: password of the truststore +* `http.ssl.truststore_algorithm`: truststore format (defaults to SunX509) +* `http.ssl.client.auth`: true|false (defaults to true) +* `http.ssl.ciphers`: Supported ciphers, defaults to `TLS_RSA_WITH_AES_128_CBC_SHA256` and `TLS_RSA_WITH_AES_128_CBC_SHA` + +== Generating certificates + +=== Using self signed certificates per node + +=== Using an own CA diff --git a/pom.xml b/pom.xml index 83d4a98f097..83fa39c9f63 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,13 @@ 2.1.2 test + + log4j + log4j + 1.2.17 + test + + diff --git a/src/main/java/org/elasticsearch/shield/SecurityModule.java b/src/main/java/org/elasticsearch/shield/SecurityModule.java index 0afe5ddc7e4..d36fee50932 100644 --- a/src/main/java/org/elasticsearch/shield/SecurityModule.java +++ b/src/main/java/org/elasticsearch/shield/SecurityModule.java @@ -29,6 +29,11 @@ public class SecurityModule extends AbstractModule implements SpawnModules { @Override public Iterable spawnModules() { + // dont spawn module in client mode + if (settings.getAsBoolean("node.client", false)) { + return ImmutableList.of(); + } + return ImmutableList.of( Modules.createModule(AuthenticationModule.class, settings), Modules.createModule(AuthorizationModule.class, settings), diff --git a/src/main/java/org/elasticsearch/shield/ssl/ElasticsearchSSLException.java b/src/main/java/org/elasticsearch/shield/ssl/ElasticsearchSSLException.java new file mode 100644 index 00000000000..26031d67fbe --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/ElasticsearchSSLException.java @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.rest.RestStatus; + +/** + * + */ +public class ElasticsearchSSLException extends ElasticsearchException { + + public ElasticsearchSSLException(String msg) { + super(msg); + } + + public ElasticsearchSSLException(String msg, Throwable cause) { + super(msg, cause); + } + + @Override + public RestStatus status() { + return RestStatus.BAD_REQUEST; + } + +} diff --git a/src/main/java/org/elasticsearch/shield/ssl/SSLConfig.java b/src/main/java/org/elasticsearch/shield/ssl/SSLConfig.java new file mode 100644 index 00000000000..0977df77ac3 --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/SSLConfig.java @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; + +import javax.net.ssl.*; +import java.io.FileInputStream; +import java.security.KeyStore; +import java.util.Arrays; + +/** + * + */ +public class SSLConfig { + + private static final ESLogger logger = Loggers.getLogger(SSLConfig.class); + // TODO removing the second one results in fails, need to verify the differences, maybe per JVM? + public static final String[] DEFAULT_CIPHERS = new String[] { "TLS_RSA_WITH_AES_128_CBC_SHA256", "TLS_RSA_WITH_AES_128_CBC_SHA" }; + private final boolean clientAuth; + + private SSLContext sslContext; + private String[] ciphers; + + public SSLConfig(Settings componentSettings) { + this(componentSettings, ImmutableSettings.EMPTY); + } + + public SSLConfig(Settings componentSettings, Settings settings) { + this.clientAuth = componentSettings.getAsBoolean("client.auth", settings.getAsBoolean("ssl.client.auth", true)); + String keyStore = componentSettings.get("keystore", settings.get("ssl.keystore", System.getProperty("javax.net.ssl.keyStore"))); + String keyStorePassword = componentSettings.get("keystore_password", settings.get("ssl.keystore_password", System.getProperty("javax.net.ssl.keyStorePassword"))); + String keyStoreAlgorithm = componentSettings.get("keystore_algorithm", settings.get("ssl.keystore_algorithm", System.getProperty("ssl.KeyManagerFactory.algorithm"))); + String trustStore = componentSettings.get("truststore", settings.get("ssl.truststore", System.getProperty("javax.net.ssl.trustStore"))); + String trustStorePassword = componentSettings.get("truststore_password", settings.get("ssl.truststore_password", System.getProperty("javax.net.ssl.trustStorePassword"))); + String trustStoreAlgorithm = componentSettings.get("truststore_algorithm", settings.get("ssl.truststore_algorithm", System.getProperty("ssl.TrustManagerFactory.algorithm"))); + this.ciphers = componentSettings.getAsArray("ciphers", settings.getAsArray("ssl.ciphers", DEFAULT_CIPHERS)); + + if (keyStoreAlgorithm == null) { + keyStoreAlgorithm = KeyManagerFactory.getDefaultAlgorithm(); + } + + if (trustStoreAlgorithm == null) { + trustStoreAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); + } + + logger.debug("using keyStore[{}], keyAlgorithm[{}], trustStore[{}], trustAlgorithm[{}]", keyStore, keyStoreAlgorithm, trustStore, trustStoreAlgorithm); + + KeyStore ks = null; + KeyManagerFactory kmf = null; + try (FileInputStream in = new FileInputStream(keyStore)){ + // Load KeyStore + ks = KeyStore.getInstance("jks"); + ks.load(in, keyStorePassword.toCharArray()); + + // Initialize KeyManagerFactory + kmf = KeyManagerFactory.getInstance(keyStoreAlgorithm); + kmf.init(ks, keyStorePassword.toCharArray()); + } catch (Exception e) { + throw new ElasticsearchException("Failed to initialize a KeyManagerFactory", e); + } + + TrustManager[] trustManagers = null; + try (FileInputStream in = new FileInputStream(trustStore)) { + // Load TrustStore + ks.load(in, trustStorePassword.toCharArray()); + + // Initialize a trust manager factory with the trusted store + TrustManagerFactory trustFactory = TrustManagerFactory.getInstance(trustStoreAlgorithm); + trustFactory.init(ks); + + // Retrieve the trust managers from the factory + trustManagers = trustFactory.getTrustManagers(); + } catch (Exception e) { + throw new ElasticsearchException("Failed to initialize a TrustManagerFactory", e); + } + + // Initialize sslContext + try { + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), trustManagers, null); + } catch (Exception e) { + throw new Error("Failed to initialize the SSLContext", e); + } + + } + + public SSLEngine createSSLEngine() { + SSLEngine sslEngine = sslContext.createSSLEngine(); + try { + sslEngine.setEnabledCipherSuites(ciphers); + } catch (Throwable t) { + throw new ElasticsearchSSLException("Error loading cipher suites ["+Arrays.asList(ciphers)+"]", t); + } + sslEngine.setNeedClientAuth(clientAuth); + return sslEngine; + } + +} diff --git a/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransport.java b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransport.java new file mode 100644 index 00000000000..b73d4f2a4aa --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransport.java @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl.netty; + +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.netty.channel.ChannelPipeline; +import org.elasticsearch.common.netty.channel.ChannelPipelineFactory; +import org.elasticsearch.common.netty.handler.ssl.SslHandler; +import org.elasticsearch.common.network.NetworkService; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.BigArrays; +import org.elasticsearch.http.netty.NettyHttpServerTransport; +import org.elasticsearch.shield.ssl.SSLConfig; + +import javax.net.ssl.SSLEngine; + +/** + * + */ +public class NettySSLHttpServerTransport extends NettyHttpServerTransport { + + private final boolean ssl; + + @Inject + public NettySSLHttpServerTransport(Settings settings, NetworkService networkService, BigArrays bigArrays) { + super(settings, networkService, bigArrays); + this.ssl = settings.getAsBoolean("http.ssl", false); + } + + @Override + public ChannelPipelineFactory configureServerChannelPipelineFactory() { + return new HttpSslChannelPipelineFactory(this); + } + + private class HttpSslChannelPipelineFactory extends HttpChannelPipelineFactory { + + public HttpSslChannelPipelineFactory(NettyHttpServerTransport transport) { + super(transport); + } + + @Override + public ChannelPipeline getPipeline() throws Exception { + ChannelPipeline pipeline = super.getPipeline(); + if (ssl) { + SSLConfig sslConfig = new SSLConfig(settings.getByPrefix("http.ssl.")); + SSLEngine engine = sslConfig.createSSLEngine(); + engine.setUseClientMode(false); + // TODO MAKE ME CONFIGURABLE + engine.setNeedClientAuth(false); + pipeline.addFirst("ssl", new SslHandler(engine)); + } + return pipeline; + } + } +} diff --git a/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransportModule.java b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransportModule.java new file mode 100644 index 00000000000..781dfd3e3b6 --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLHttpServerTransportModule.java @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl.netty; + +import org.elasticsearch.common.inject.AbstractModule; +import org.elasticsearch.http.HttpServerTransport; +import org.elasticsearch.http.netty.NettyHttpServerTransport; + +/** + * + */ +public class NettySSLHttpServerTransportModule extends AbstractModule { + + @Override + protected void configure() { + bind(HttpServerTransport.class).to(NettySSLHttpServerTransport.class).asEagerSingleton(); + } + +} diff --git a/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransport.java b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransport.java new file mode 100644 index 00000000000..9d584b34b5b --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransport.java @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl.netty; + +import org.elasticsearch.Version; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.netty.channel.ChannelPipeline; +import org.elasticsearch.common.netty.channel.ChannelPipelineFactory; +import org.elasticsearch.common.netty.handler.ssl.SslHandler; +import org.elasticsearch.common.network.NetworkService; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.BigArrays; +import org.elasticsearch.shield.ssl.SSLConfig; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.netty.NettyTransport; + +import javax.net.ssl.SSLEngine; + +/** + * + */ +public class NettySSLTransport extends NettyTransport { + + private final boolean ssl; + + @Inject + public NettySSLTransport(Settings settings, ThreadPool threadPool, NetworkService networkService, BigArrays bigArrays, Version version) { + super(settings, threadPool, networkService, bigArrays, version); + this.ssl = settings.getAsBoolean("transport.tcp.ssl", false); + } + + @Override + public ChannelPipelineFactory configureClientChannelPipelineFactory() { + return new SslClientChannelPipelineFactory(this); + } + + @Override + public ChannelPipelineFactory configureServerChannelPipelineFactory() { + return new SslServerChannelPipelineFactory(this); + } + + private class SslServerChannelPipelineFactory extends ServerChannelPipeFactory { + + private final SSLConfig sslConfig; + + public SslServerChannelPipelineFactory(NettyTransport nettyTransport) { + super(nettyTransport); + sslConfig = new SSLConfig(settings.getByPrefix("transport.tcp.ssl.")); + // try to create an SSL engine, so that exceptions lead to early exit + sslConfig.createSSLEngine(); + } + + @Override + public ChannelPipeline getPipeline() throws Exception { + ChannelPipeline pipeline = super.getPipeline(); + if (ssl) { + SSLEngine serverEngine = sslConfig.createSSLEngine(); + serverEngine.setUseClientMode(false); + + pipeline.addFirst("ssl", new SslHandler(serverEngine)); + pipeline.replace("dispatcher", "dispatcher", new SecureMessageChannelHandler(nettyTransport, logger)); + } + return pipeline; + } + } + + private class SslClientChannelPipelineFactory extends ClientChannelPipelineFactory { + + private final SSLConfig sslConfig; + + public SslClientChannelPipelineFactory(NettyTransport transport) { + super(transport); + sslConfig = new SSLConfig(settings.getByPrefix("transport.tcp.ssl.")); + // try to create an SSL engine, so that exceptions lead to early exit + sslConfig.createSSLEngine(); + } + + @Override + public ChannelPipeline getPipeline() throws Exception { + ChannelPipeline pipeline = super.getPipeline(); + if (ssl) { + SSLEngine clientEngine = sslConfig.createSSLEngine(); + clientEngine.setUseClientMode(true); + + pipeline.addFirst("ssl", new SslHandler(clientEngine)); + pipeline.replace("dispatcher", "dispatcher", new SecureMessageChannelHandler(nettyTransport, logger)); + } + return pipeline; + } + } +} diff --git a/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransportModule.java b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransportModule.java new file mode 100644 index 00000000000..a96562c4a08 --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/netty/NettySSLTransportModule.java @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl.netty; + +import org.elasticsearch.common.inject.AbstractModule; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.transport.Transport; + +/** + * + */ +public class NettySSLTransportModule extends AbstractModule { + + private final Settings settings; + + public NettySSLTransportModule(Settings settings) { + this.settings = settings; + } + + @Override + protected void configure() { + bind(NettySSLTransport.class).asEagerSingleton(); + bind(Transport.class).to(NettySSLTransport.class); + } +} diff --git a/src/main/java/org/elasticsearch/shield/ssl/netty/SecureMessageChannelHandler.java b/src/main/java/org/elasticsearch/shield/ssl/netty/SecureMessageChannelHandler.java new file mode 100644 index 00000000000..760e374345c --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/ssl/netty/SecureMessageChannelHandler.java @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl.netty; + +import org.elasticsearch.shield.ssl.ElasticsearchSSLException; +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.netty.channel.ChannelFuture; +import org.elasticsearch.common.netty.channel.ChannelFutureListener; +import org.elasticsearch.common.netty.channel.ChannelHandlerContext; +import org.elasticsearch.common.netty.channel.ChannelStateEvent; +import org.elasticsearch.common.netty.handler.ssl.SslHandler; +import org.elasticsearch.transport.netty.MessageChannelHandler; + +public class SecureMessageChannelHandler extends MessageChannelHandler { + + public SecureMessageChannelHandler(org.elasticsearch.transport.netty.NettyTransport transport, ESLogger logger) { + super(transport, logger); + } + + @Override + public void channelConnected(final ChannelHandlerContext ctx, final ChannelStateEvent e) throws Exception { + SslHandler sslHandler = ctx.getPipeline().get(SslHandler.class); + + // Get notified when SSL handshake is done. + final ChannelFuture handshakeFuture = sslHandler.handshake(); + handshakeFuture.addListener(new ChannelFutureListener() { + @Override + public void operationComplete(ChannelFuture future) throws Exception { + if (future.isSuccess()) { + logger.debug("SSL / TLS handshake completed for the channel."); + ctx.sendUpstream(e); + } else { + logger.error("SSL / TLS handshake failed, closing the channel"); + future.getChannel().close(); + throw new ElasticsearchSSLException("SSL / TLS handshake failed, closing the channel", future.getCause()); + } + } + }); + } +} diff --git a/src/test/java/org/elasticsearch/shield/plugin/ShieldPluginTests.java b/src/test/java/org/elasticsearch/shield/plugin/ShieldPluginTests.java index d2429c2abd8..9ea41e04f60 100644 --- a/src/test/java/org/elasticsearch/shield/plugin/ShieldPluginTests.java +++ b/src/test/java/org/elasticsearch/shield/plugin/ShieldPluginTests.java @@ -21,7 +21,7 @@ import static org.elasticsearch.test.ElasticsearchIntegrationTest.Scope; /** * */ -@ClusterScope(scope = Scope.SUITE) +@ClusterScope(scope = Scope.SUITE, numDataNodes = 2) public class ShieldPluginTests extends ElasticsearchIntegrationTest { @Override @@ -36,7 +36,9 @@ public class ShieldPluginTests extends ElasticsearchIntegrationTest { @Test @TestLogging("_root:INFO,plugins.PluginsService:TRACE") public void testThatPluginIsLoaded() { - NodesInfoResponse nodeInfos = internalCluster().clientNodeClient().admin().cluster().prepareNodesInfo().get(); + logger.info("--> Getting nodes info"); + NodesInfoResponse nodeInfos = internalCluster().transportClient().admin().cluster().prepareNodesInfo().get(); + logger.info("--> Checking nodes info"); for (NodeInfo nodeInfo : nodeInfos.getNodes()) { assertThat(nodeInfo.getPlugins().getInfos(), hasSize(1)); assertThat(nodeInfo.getPlugins().getInfos().get(0).getName(), is("shield")); diff --git a/src/test/java/org/elasticsearch/shield/ssl/SslIntegrationTests.java b/src/test/java/org/elasticsearch/shield/ssl/SslIntegrationTests.java new file mode 100644 index 00000000000..6039ff175dd --- /dev/null +++ b/src/test/java/org/elasticsearch/shield/ssl/SslIntegrationTests.java @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.ssl; + +import com.carrotsearch.ant.tasks.junit4.dependencies.com.google.common.base.Charsets; +import com.google.common.net.InetAddresses; +import org.elasticsearch.shield.ssl.ElasticsearchSSLException; +import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; +import org.elasticsearch.action.admin.cluster.health.ClusterHealthStatus; +import org.elasticsearch.client.Client; +import org.elasticsearch.client.transport.TransportClient; +import org.elasticsearch.common.io.Streams; +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.InetSocketTransportAddress; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.http.HttpServerTransport; +import org.elasticsearch.node.Node; +import org.elasticsearch.node.NodeBuilder; +import org.elasticsearch.shield.plugin.SecurityPlugin; +import org.elasticsearch.shield.ssl.netty.NettySSLHttpServerTransportModule; +import org.elasticsearch.shield.ssl.netty.NettySSLTransportModule; +import org.elasticsearch.test.ElasticsearchIntegrationTest; +import org.elasticsearch.test.junit.annotations.TestLogging; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.transport.TransportModule; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import javax.net.ssl.*; +import java.io.File; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Locale; + +import static org.elasticsearch.test.ElasticsearchIntegrationTest.ClusterScope; +import static org.elasticsearch.test.ElasticsearchIntegrationTest.Scope; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoTimeout; +import static org.hamcrest.Matchers.*; + +/** + * Created a testnode cert and a test client cert, which is imported into the keystore + * + * keytool -genkeypair -alias testnode -keystore testnode.jks -keyalg RSA -storepass testnode -keypass testnode -dname "cn=Elasticsearch Test Node, ou=elasticsearch, o=org" + * keytool -export -alias testnode -keystore testnode.jks -rfc -file testnode.cert -storepass testnode + * + * keytool -genkeypair -alias testclient -keystore testclient.jks -keyalg RSA -storepass testclient -keypass testclient -dname "cn=Elasticsearch Test Client, ou=elasticsearch, o=org" + * keytool -export -alias testclient -keystore testclient.jks -rfc -file testclient.cert -storepass testclient + * + * keytool -import -trustcacerts -alias testclient -file testclient.cert -keystore testnode.jks -storepass testnode -noprompt + * keytool -import -trustcacerts -alias testnode -file testnode.cert -keystore testclient.jks -storepass testclient -noprompt + * + */ +@ClusterScope(scope = Scope.SUITE, numDataNodes = 1, transportClientRatio = 0.0, numClientNodes = 0) +public class SslIntegrationTests extends ElasticsearchIntegrationTest { + + /* + # transport.tcp.ssl.keystore: /path/to/the/keystore + # transport.tcp.ssl.keystore_password: password + # transport.tcp.ssl.keystore_algorithm: SunX509 + # + # transport.tcp.ssl.truststore: /path/to/the/truststore + # transport.tcp.ssl.truststore_password: password + # transport.tcp.ssl.truststore_algorithm: PKIX + */ + @Override + protected Settings nodeSettings(int nodeOrdinal) { + File testnodeStore; + try { + testnodeStore = new File(getClass().getResource("/certs/simple/testnode.jks").toURI()); + assertThat(testnodeStore.exists(), is(true)); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return ImmutableSettings.settingsBuilder() + .put(super.nodeSettings(nodeOrdinal)) + .put("discovery.zen.ping.multicast.ping.enabled", false) + // needed to ensure that netty transport is started + .put("node.mode", "network") + .put("transport.tcp.ssl", true) + .put("transport.tcp.ssl.keystore", testnodeStore.getPath()) + .put("transport.tcp.ssl.keystore_password", "testnode") + .put("transport.tcp.ssl.truststore", testnodeStore.getPath()) + .put("transport.tcp.ssl.truststore_password", "testnode") + .put("http.ssl", true) + .put("http.ssl.keystore", testnodeStore.getPath()) + .put("http.ssl.keystore_password", "testnode") + .put("http.ssl.truststore", testnodeStore.getPath()) + .put("http.ssl.truststore_password", "testnode") + // SSL SETUP + .put("http.type", NettySSLHttpServerTransportModule.class.getName()) + .put(TransportModule.TRANSPORT_TYPE_KEY, NettySSLTransportModule.class.getName()) + .put("plugin.types", SecurityPlugin.class.getName()) + .build(); + } + + @Before + public void setup() { + System.setProperty("javax.net.debug", "all"); + } + + @After + public void teardown() { + System.clearProperty("javax.net.debug"); + } + + @Test + @TestLogging("_root:INFO,org.elasticsearch.test:TRACE, org.elasticsearch.client.transport:DEBUG,org.elasticsearch.shield:TRACE") + public void testThatTransportClientCanConnectToNodeViaSsl() throws Exception { + TransportClient transportClient = new TransportClient(getSettings("transport_client").build(), false); + TransportAddress transportAddress = internalCluster().getInstance(Transport.class).boundAddress().boundAddress(); + transportClient.addTransportAddress(transportAddress); + + assertGreenClusterState(transportClient); + } + + @Test(expected = ElasticsearchSSLException.class) + @TestLogging("_root:INFO,org.elasticsearch.client.transport:DEBUG") + public void testThatUnconfiguredCipchersAreRejected() { + // some randomly taken ciphers from SSLContext.getDefault().getSocketFactory().getSupportedCipherSuites() + // could be really randomized + Settings customSettings = getSettings("transport_client").put("transport.tcp.ssl.ciphers", new String[]{"TLS_ECDH_anon_WITH_RC4_128_SHA", "SSL_RSA_WITH_3DES_EDE_CBC_SHA"}).build(); + + TransportClient transportClient = new TransportClient(customSettings); + + TransportAddress transportAddress = internalCluster().getInstance(Transport.class).boundAddress().boundAddress(); + transportClient.addTransportAddress(transportAddress); + + transportClient.admin().cluster().prepareHealth().get(); + } + + @Test + public void testConnectNodeWorks() throws Exception { + try (Node node = NodeBuilder.nodeBuilder().settings(getSettings("ssl_node")).node().start()) { + try (Client client = node.client()) { + assertGreenClusterState(client); + } + } + } + + @Test + public void testConnectNodeClientWorks() throws Exception { + // no multicast, good old discovery + TransportAddress transportAddress = internalCluster().getInstance(Transport.class).boundAddress().boundAddress(); + assertThat(transportAddress, is(instanceOf(InetSocketTransportAddress.class))); + InetSocketTransportAddress inetSocketTransportAddress = (InetSocketTransportAddress) transportAddress; + Settings.Builder settingsBuilder = getSettings("node_client") + .put("node.client", true) + .put("discovery.zen.ping.multicast.ping.enabled", false) + .put("discovery.zen.ping.unicast.hosts", inetSocketTransportAddress.address().getHostString() + ":" + inetSocketTransportAddress.address().getPort()); + + try (Node node = NodeBuilder.nodeBuilder().settings(settingsBuilder).node().start()) { + try (Client client = node.client()) { + assertGreenClusterState(client); + } + } + } + + @Test(expected = ElasticsearchSSLException.class) + public void testConnectNodeFailsWithWrongCipher() throws Exception { + Settings customSettings = getSettings("ssl_node").put("transport.tcp.ssl.ciphers", new String[]{"TLS_ECDH_anon_WITH_RC4_128_SHA", "SSL_RSA_WITH_3DES_EDE_CBC_SHA"}).build(); + NodeBuilder.nodeBuilder().settings(customSettings).node().start(); + } + + @Test(expected = ElasticsearchSSLException.class) + public void testConnectNodeClientFailsWithWrongCipher() throws Exception { + Settings customSettings = getSettings("ssl_node").put("node.client", true).put("transport.tcp.ssl.ciphers", new String[]{"TLS_ECDH_anon_WITH_RC4_128_SHA", "SSL_RSA_WITH_3DES_EDE_CBC_SHA"}).build(); + NodeBuilder.nodeBuilder().settings(customSettings).node().start(); + } + + @Test + public void testThatConnectionToHTTPWorks() throws Exception { + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkClientTrusted( + java.security.cert.X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted( + java.security.cert.X509Certificate[] certs, String authType) { + } + } + }; + + // Install the all-trusting trust manager + SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(null, trustAllCerts, new java.security.SecureRandom()); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + // totally secure + HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() { + @Override + public boolean verify(String s, SSLSession sslSession) { + return true; + } + }); + + TransportAddress transportAddress = internalCluster().getInstance(HttpServerTransport.class).boundAddress().boundAddress(); + assertThat(transportAddress, is(instanceOf(InetSocketTransportAddress.class))); + InetSocketTransportAddress inetSocketTransportAddress = (InetSocketTransportAddress) transportAddress; + String url = String.format(Locale.ROOT, "https://%s:%s/", InetAddresses.toUriString(inetSocketTransportAddress.address().getAddress()), inetSocketTransportAddress.address().getPort()); + + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.connect(); + + assertThat(connection.getResponseCode(), is(200)); + String data = Streams.copyToString(new InputStreamReader(connection.getInputStream(), Charsets.UTF_8)); + assertThat(data, containsString("You Know, for Search")); + } + + private ImmutableSettings.Builder getSettings(String name) { + File testClientKeyStore; + File testClientTrustStore; + try { + testClientKeyStore = new File(getClass().getResource("/certs/simple/testclient.jks").toURI()); + testClientTrustStore = new File(getClass().getResource("/certs/simple/testclient.jks").toURI()); + } catch (Exception e) { + throw new RuntimeException(e); + } + assertThat(testClientKeyStore.exists(), is(true)); + assertThat(testClientTrustStore.exists(), is(true)); + + return ImmutableSettings.settingsBuilder() + .put("node.name", name) + .put("transport.tcp.ssl", true) + .put("transport.tcp.ssl.keystore", testClientKeyStore.getPath()) + .put("transport.tcp.ssl.keystore_password", "testclient") + .put("transport.tcp.ssl.truststore", testClientTrustStore .getPath()) + .put("transport.tcp.ssl.truststore_password", "testclient") + .put("discovery.zen.ping.multicast.ping.enabled", false) + .put(TransportModule.TRANSPORT_TYPE_KEY, NettySSLTransportModule.class.getName()) + //.put("plugin.types", SecurityPlugin.class.getName()) + .put("cluster.name", internalCluster().getClusterName()); + } + + private void assertGreenClusterState(Client client) { + ClusterHealthResponse clusterHealthResponse = client.admin().cluster().prepareHealth().get(); + assertNoTimeout(clusterHealthResponse); + assertThat(clusterHealthResponse.getStatus(), is(ClusterHealthStatus.GREEN)); + } +} diff --git a/src/test/resources/certs/simple/testclient.cert b/src/test/resources/certs/simple/testclient.cert new file mode 100644 index 00000000000..ec3f998b223 --- /dev/null +++ b/src/test/resources/certs/simple/testclient.cert @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIIDMzCCAhugAwIBAgIEIqIBljANBgkqhkiG9w0BAQsFADBKMQwwCgYDVQQKEwNvcmcxFjAUBgNV +BAsTDWVsYXN0aWNzZWFyY2gxIjAgBgNVBAMTGUVsYXN0aWNzZWFyY2ggVGVzdCBDbGllbnQwHhcN +MTQwNzIyMDc1MzA1WhcNMTQxMDIwMDc1MzA1WjBKMQwwCgYDVQQKEwNvcmcxFjAUBgNVBAsTDWVs +YXN0aWNzZWFyY2gxIjAgBgNVBAMTGUVsYXN0aWNzZWFyY2ggVGVzdCBDbGllbnQwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCZTqtl7W0u/e7GAqyFhbC4DLlBByH7B0ZwPdh/ulVfIzDi +Er7zkOGQtxkrsWHQZmfeyroLwKhfIaA69Jvd+JRFqn53zW3iziduXeMs9qnNP6Mb1ePmBmrs6icU +J1Wcxz5O+JofJ8g3+9KWPHVS4Ls1or43W14fV5LAnSt5lOhC33ayawUstls3mdb2LUvqkgZTwFxd +SwRAB6o6x+5JLrA3p2sVh97C4PyC5BxfR7EWfR6/gc7BvcqP9yxfKPf4TmaufStNQ3ESoH5wzGUy +i/XWUy0GYjaqk+DM1RTjhVetDr2jPl2akILqA0W3pTtjutsVxmN0W8EN9hGBlCWHyxjHAgMBAAGj +ITAfMB0GA1UdDgQWBBR15qT6iIEGeeeE7IMY9ZxGhDQHWzANBgkqhkiG9w0BAQsFAAOCAQEAdhic +qBw2TqnkOZOtRCxt5u4DnZE63i1W//kuFEjf0ee6/PFpPlKLROCzBCnf/ys49ixg6Q50H6U0SKwL +bMhdyAqfZakEknR8uTpvWP3WA+BcCuUB2drlvd3DWI+FF889otOwacNOrwyHnrN7Uv18Hh9PPcyD +x3V9uZSWzIQ4hU2+Fe0HHLenxIUFh6nqfCBy+81+AKQQuN4vE7TRUq/2WIK5rmwB8pakEoyc15ks +i/NGa1GjUvfM8G4FprINJ+Xrg6kcC7SL5pJVMCA+8c/iMPm22/XOYj7m4UL9BXKW9FFJX+3AMVZm +I4xHtBAZU4nkbnvDrbmsQvzGAp565LBv1g== +-----END CERTIFICATE----- diff --git a/src/test/resources/certs/simple/testclient.jks b/src/test/resources/certs/simple/testclient.jks new file mode 100644 index 0000000000000000000000000000000000000000..5e9496e49968caf4fb4e5aee5faeb76f9c0a7567 GIT binary patch literal 3032 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 nm^h3-w5GkIL_)35_^D}3aK>#^gzJ+$Q