Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions distribution/src/bin/elasticsearch
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@ source "`dirname "$0"`"/elasticsearch-env

CHECK_KEYSTORE=true
DAEMONIZE=false
for option in "$@"; do
case "$option" in
-h|--help|-V|--version)
CHECK_KEYSTORE=false
;;
-d|--daemonize)
DAEMONIZE=true
;;
esac
ENROLL_TO_CLUSTER=false
# Store original arg array as we will be shifting through it below
ARG_LIST=$@
while [ $# -gt 0 ]; do
if [[ $1 == "--enrollment-token" ]]; then
ENROLL_TO_CLUSTER=true
ENROLLMENT_TOKEN="$2"
elif [[ $1 == "-h" || $1 == "--help" || $1 == "-V" || $1 == "--version" ]]; then
CHECK_KEYSTORE=false
elif [[ $1 == "-d" || $1 == "--daemonize" ]]; then
DAEMONIZE=true
fi
shift
done

if [ -z "$ES_TMPDIR" ]; then
Expand All @@ -45,6 +49,14 @@ then
fi
fi

if [[ $ENROLL_TO_CLUSTER = true ]]; then
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea here is that this part runs only when an explicit flag is passed by the user. We will attempt to do our auto-configuration of TLS based on the enrollment process and only if everything went well will the node start with that configuration. If anything fails, we roll-back the configuration and fail with an error message.

ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.EnrollNodeToCluster \
ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \
ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli \
"`dirname "$0"`"/elasticsearch-cli \
--enrollment-token "$ENROLLMENT_TOKEN"
fi

# The JVM options parser produces the final JVM options to start Elasticsearch.
# It does this by incorporating JVM options in the following way:
# - first, system JVM options are applied (these are hardcoded options in the
Expand All @@ -67,7 +79,7 @@ if [[ $DAEMONIZE = false ]]; then
-Des.bundled_jdk="$ES_BUNDLED_JDK" \
-cp "$ES_CLASSPATH" \
org.elasticsearch.bootstrap.Elasticsearch \
"$@" <<<"$KEYSTORE_PASSWORD"
"$ARG_LIST" <<<"$KEYSTORE_PASSWORD"
else
exec \
"$JAVA" \
Expand All @@ -80,7 +92,7 @@ else
-Des.bundled_jdk="$ES_BUNDLED_JDK" \
-cp "$ES_CLASSPATH" \
org.elasticsearch.bootstrap.Elasticsearch \
"$@" \
"$ARG_LIST" \
<<<"$KEYSTORE_PASSWORD" &
retval=$?
pid=$!
Expand Down
13 changes: 13 additions & 0 deletions distribution/src/bin/elasticsearch.bat
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ setlocal enableextensions

SET params='%*'
SET checkpassword=Y
SET enrolltocluster=N

:loop
FOR /F "usebackq tokens=1* delims= " %%A IN (!params!) DO (
Expand Down Expand Up @@ -33,6 +34,11 @@ FOR /F "usebackq tokens=1* delims= " %%A IN (!params!) DO (
SET checkpassword=N
)

IF "!current!" == "--enrollment-token" (
SHIFT
SET enrollmenttoken=%~1
)

IF "!silent!" == "Y" (
SET nopauseonerror=Y
) ELSE (
Expand Down Expand Up @@ -68,6 +74,13 @@ IF "%checkpassword%"=="Y" (
)
)

set ES_MAIN_CLASS=org.elasticsearch.xpack.security.cli.EnrollNodeToCluster
set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env
set ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli
call "%~dp0elasticsearch-cli.bat" --enrollment-token %enrollmenttoken%
|| goto exit


if not defined ES_TMPDIR (
for /f "tokens=* usebackq" %%a in (`CALL %JAVA% -cp "!ES_CLASSPATH!" "org.elasticsearch.tools.launchers.TempDirectory"`) do set ES_TMPDIR=%%a
)
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog/77292.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 77292
summary: Enroll additional nodes to cluster
area: "Infra/CLI, Security, Packaging"
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ private static PrivateKey parsePKCS8(BufferedReader bReader) throws IOException,
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}

public static PrivateKey parsePKCS8PemString(String pem) throws IOException, GeneralSecurityException{
byte[] keyBytes = Base64.getDecoder().decode(pem);
String keyAlgo = getKeyAlgorithmIdentifier(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(keyAlgo);
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}

/**
* Creates a {@link PrivateKey} from the contents of {@code bReader} that contains an EC private key encoded in
* OpenSSL traditional format.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
package org.elasticsearch.xpack.security.tool;
package org.elasticsearch.xpack.core.security;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved CommandLineHttpClient, HttpResponse and EnrollmentToken to core so that I can use them in EnrollNodeToCluster without needing to add a dependency to the security plugin in security:cli. The issue with that would be that I'd have a dependency conflict for Guava (30-1 as a jimfs dependency in security:cli vs 19 in security) I couldn't think of a way to solve this dependency issue, but more than happy to get suggestions and I'll move this back to the security plugin


import org.elasticsearch.common.hash.MessageDigests;
import org.elasticsearch.common.io.Streams;
import org.elasticsearch.core.CharArrays;
import org.elasticsearch.core.CheckedFunction;
import org.elasticsearch.common.CheckedSupplier;
import org.elasticsearch.common.Strings;
Expand All @@ -23,9 +25,12 @@
import org.elasticsearch.xpack.core.common.socket.SocketAccess;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.core.ssl.SSLService;
import org.elasticsearch.xpack.security.tool.HttpResponse.HttpResponseBuilder;
import org.elasticsearch.xpack.core.security.HttpResponse.HttpResponseBuilder;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
Expand All @@ -34,9 +39,16 @@
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.security.MessageDigest;
import java.security.PrivilegedExceptionAction;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand All @@ -60,9 +72,16 @@ public class CommandLineHttpClient {
private static final int READ_TIMEOUT = 35 * 1000;

private final Environment env;
private final String pinnedCaCertFingerprint;

public CommandLineHttpClient(Environment env) {
this.env = env;
this.pinnedCaCertFingerprint = null;
}

public CommandLineHttpClient(Environment env, String pinnedCaCertFingerprint) {
this.env = env;
this.pinnedCaCertFingerprint = pinnedCaCertFingerprint;
}

/**
Expand All @@ -79,22 +98,55 @@ public CommandLineHttpClient(Environment env) {
* handler of the response Input Stream.
* @return HTTP protocol response code.
*/
@SuppressForbidden(reason = "We call connect in doPrivileged and provide SocketPermission")
public HttpResponse execute(String method, URL url, String user, SecureString password,
CheckedSupplier<String, Exception> requestBodySupplier,
CheckedFunction<InputStream, HttpResponseBuilder, Exception> responseHandler) throws Exception {

final String authorizationHeader = UsernamePasswordToken.basicAuthHeaderValue(user, password);
return execute(method, url, authorizationHeader, requestBodySupplier, responseHandler);
}

/**
* General purpose HTTP(S) call with JSON Content-Type and Authorization Header.
* SSL settings are read from the settings file, if any.
*
* @param apiKey
* API key value to be used in the Authorization header
* @param requestBodySupplier
* supplier for the JSON string body of the request.
* @param responseHandler
* handler of the response Input Stream.
* @return HTTP protocol response code.
*/
public HttpResponse execute(String method, URL url, SecureString apiKey,
CheckedSupplier<String, Exception> requestBodySupplier,
CheckedFunction<InputStream, HttpResponseBuilder, Exception> responseHandler) throws Exception {
final String authorizationHeaderValue = apiKeyHeaderValue(apiKey);
return execute(method, url, authorizationHeaderValue, requestBodySupplier, responseHandler);
}

@SuppressForbidden(reason = "We call connect in doPrivileged and provide SocketPermission")
private HttpResponse execute(String method, URL url, String authorizationHeader,
CheckedSupplier<String, Exception> requestBodySupplier,
CheckedFunction<InputStream, HttpResponseBuilder, Exception> responseHandler) throws Exception {
final HttpURLConnection conn;
// If using SSL, need a custom service because it's likely a self-signed certificate
if ("https".equalsIgnoreCase(url.getProtocol())) {
final SSLService sslService = new SSLService(env);
final HttpsURLConnection httpsConn = (HttpsURLConnection) url.openConnection();
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
final SslConfiguration sslConfiguration = sslService.getHttpTransportSSLConfiguration();
// Requires permission java.lang.RuntimePermission "setFactory";
httpsConn.setSSLSocketFactory(sslService.sslSocketFactory(sslConfiguration));
final boolean isHostnameVerificationEnabled = sslConfiguration.getVerificationMode().isHostnameVerificationEnabled();
if (isHostnameVerificationEnabled == false) {
httpsConn.setHostnameVerifier((hostname, session) -> true);
AccessController.doPrivileged((PrivilegedExceptionAction<Void>) () -> {
if (pinnedCaCertFingerprint != null) {
final SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[] { fingerprintTrustingTrustManager(pinnedCaCertFingerprint) }, null);
httpsConn.setSSLSocketFactory(sslContext.getSocketFactory());
} else {
final SslConfiguration sslConfiguration = sslService.getHttpTransportSSLConfiguration();
// Requires permission java.lang.RuntimePermission "setFactory";
httpsConn.setSSLSocketFactory(sslService.sslSocketFactory(sslConfiguration));
final boolean isHostnameVerificationEnabled = sslConfiguration.getVerificationMode().isHostnameVerificationEnabled();
if (isHostnameVerificationEnabled == false) {
httpsConn.setHostnameVerifier((hostname, session) -> true);
}
}
return null;
});
Expand All @@ -105,8 +157,7 @@ public HttpResponse execute(String method, URL url, String user, SecureString pa
conn.setRequestMethod(method);
conn.setReadTimeout(READ_TIMEOUT);
// Add basic-auth header
String token = UsernamePasswordToken.basicAuthHeaderValue(user, password);
conn.setRequestProperty("Authorization", token);
conn.setRequestProperty("Authorization", authorizationHeader);
conn.setRequestProperty("Content-Type", XContentType.JSON.mediaType());
String bodyString = requestBodySupplier.get();
conn.setDoOutput(bodyString != null); // set true if we are sending a body
Expand Down Expand Up @@ -253,4 +304,48 @@ public static HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) t
public static URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException {
return new URL(url, (url.toURI().getPath() + path).replaceAll("/+", "/") + query);
}

public static String apiKeyHeaderValue(SecureString apiKey) {
CharBuffer chars = CharBuffer.allocate(apiKey.length());
byte[] charBytes = null;
try {
chars.put(apiKey.getChars());
charBytes = CharArrays.toUtf8Bytes(chars.array());

//TODO we still have passwords in Strings in headers. Maybe we can look into using a CharSequence?
String apiKeyToken = Base64.getEncoder().encodeToString(charBytes);
return "ApiKey " + apiKeyToken;
} finally {
Arrays.fill(chars.array(), (char) 0);
if (charBytes != null) {
Arrays.fill(charBytes, (byte) 0);
}
}
}

/**
* Returns a TrustManager to be used in a client SSLContext, which trusts all certificates that are signed
* by a specific CA certificate ( identified by its SHA256 fingerprint, {@code pinnedCaCertFingerPrint} )
*/
private TrustManager fingerprintTrustingTrustManager(String pinnedCaCertFingerprint) {
final TrustManager trustManager = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}

public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
final Certificate caCertFromChain = chain[chain.length-1];
MessageDigest sha256 = MessageDigests.sha256();
sha256.update(caCertFromChain.getEncoded());
if (MessageDigests.toHexString(sha256.digest()).equals(pinnedCaCertFingerprint) == false ) {
throw new CertificateException();
}
}

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

return trustManager;
}
}
Loading