Skip to content

Commit 1399fb6

Browse files
authored
Enroll additional nodes to cluster (#77292)
This introduces a new CLI tool `elasticsearch-enroll-node`. It takes an Enrollment Token as a parameter and using the information in it, it attempts to - Communicate with an existing node of the cluster - Receive necessary key/certificate material - Persist said material and configuration This tool needs to be run before the first time the current node starts and if it doesn't have any explicit security related configuration already defined.
1 parent d6912eb commit 1399fb6

File tree

34 files changed

+1605
-123
lines changed

34 files changed

+1605
-123
lines changed

docs/changelog/77292.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 77292
2+
summary: Enroll additional nodes to cluster
3+
area: "Security"
4+
type: enhancement
5+
issues: []

libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/PemUtils.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,18 @@ private static PrivateKey parsePKCS8(BufferedReader bReader) throws IOException,
207207
if (null == line || PKCS8_FOOTER.equals(line.trim()) == false) {
208208
throw new IOException("Malformed PEM file, PEM footer is invalid or missing");
209209
}
210-
byte[] keyBytes = Base64.getDecoder().decode(sb.toString());
210+
return parsePKCS8PemString(sb.toString());
211+
}
212+
213+
/**
214+
* Creates a {@link PrivateKey} from a String that contains the PEM encoded representation of a plaintext private key encoded in PKCS8
215+
* @param pemString the PEM encoded representation of a plaintext private key encoded in PKCS8
216+
* @return {@link PrivateKey}
217+
* @throws IOException if the algorithm identifier can not be parsed from DER
218+
* @throws GeneralSecurityException if the private key can't be generated from the {@link PKCS8EncodedKeySpec}
219+
*/
220+
public static PrivateKey parsePKCS8PemString(String pemString) throws IOException, GeneralSecurityException{
221+
byte[] keyBytes = Base64.getDecoder().decode(pemString);
211222
String keyAlgo = getKeyAlgorithmIdentifier(keyBytes);
212223
KeyFactory keyFactory = KeyFactory.getInstance(keyAlgo);
213224
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
package org.elasticsearch.xpack.security.tool;
7+
package org.elasticsearch.xpack.core.security;
88

9+
import org.elasticsearch.common.hash.MessageDigests;
910
import org.elasticsearch.common.io.Streams;
11+
import org.elasticsearch.core.CharArrays;
1012
import org.elasticsearch.core.CheckedFunction;
1113
import org.elasticsearch.common.CheckedSupplier;
1214
import org.elasticsearch.common.Strings;
@@ -23,9 +25,12 @@
2325
import org.elasticsearch.xpack.core.common.socket.SocketAccess;
2426
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
2527
import org.elasticsearch.xpack.core.ssl.SSLService;
26-
import org.elasticsearch.xpack.security.tool.HttpResponse.HttpResponseBuilder;
28+
import org.elasticsearch.xpack.core.security.HttpResponse.HttpResponseBuilder;
2729

2830
import javax.net.ssl.HttpsURLConnection;
31+
import javax.net.ssl.SSLContext;
32+
import javax.net.ssl.TrustManager;
33+
import javax.net.ssl.X509TrustManager;
2934
import java.io.IOException;
3035
import java.io.InputStream;
3136
import java.io.OutputStream;
@@ -34,9 +39,16 @@
3439
import java.net.MalformedURLException;
3540
import java.net.URISyntaxException;
3641
import java.net.URL;
42+
import java.nio.CharBuffer;
3743
import java.nio.charset.StandardCharsets;
3844
import java.security.AccessController;
39-
import java.security.PrivilegedAction;
45+
import java.security.MessageDigest;
46+
import java.security.PrivilegedExceptionAction;
47+
import java.security.cert.Certificate;
48+
import java.security.cert.CertificateException;
49+
import java.security.cert.X509Certificate;
50+
import java.util.Arrays;
51+
import java.util.Base64;
4052
import java.util.Collections;
4153
import java.util.List;
4254
import java.util.Map;
@@ -60,9 +72,16 @@ public class CommandLineHttpClient {
6072
private static final int READ_TIMEOUT = 35 * 1000;
6173

6274
private final Environment env;
75+
private final String pinnedCaCertFingerprint;
6376

6477
public CommandLineHttpClient(Environment env) {
6578
this.env = env;
79+
this.pinnedCaCertFingerprint = null;
80+
}
81+
82+
public CommandLineHttpClient(Environment env, String pinnedCaCertFingerprint) {
83+
this.env = env;
84+
this.pinnedCaCertFingerprint = pinnedCaCertFingerprint;
6685
}
6786

6887
/**
@@ -79,22 +98,55 @@ public CommandLineHttpClient(Environment env) {
7998
* handler of the response Input Stream.
8099
* @return HTTP protocol response code.
81100
*/
82-
@SuppressForbidden(reason = "We call connect in doPrivileged and provide SocketPermission")
83101
public HttpResponse execute(String method, URL url, String user, SecureString password,
84102
CheckedSupplier<String, Exception> requestBodySupplier,
85103
CheckedFunction<InputStream, HttpResponseBuilder, Exception> responseHandler) throws Exception {
104+
105+
final String authorizationHeader = UsernamePasswordToken.basicAuthHeaderValue(user, password);
106+
return execute(method, url, authorizationHeader, requestBodySupplier, responseHandler);
107+
}
108+
109+
/**
110+
* General purpose HTTP(S) call with JSON Content-Type and Authorization Header.
111+
* SSL settings are read from the settings file, if any.
112+
*
113+
* @param apiKey
114+
* API key value to be used in the Authorization header
115+
* @param requestBodySupplier
116+
* supplier for the JSON string body of the request.
117+
* @param responseHandler
118+
* handler of the response Input Stream.
119+
* @return HTTP protocol response code.
120+
*/
121+
public HttpResponse execute(String method, URL url, SecureString apiKey,
122+
CheckedSupplier<String, Exception> requestBodySupplier,
123+
CheckedFunction<InputStream, HttpResponseBuilder, Exception> responseHandler) throws Exception {
124+
final String authorizationHeaderValue = apiKeyHeaderValue(apiKey);
125+
return execute(method, url, authorizationHeaderValue, requestBodySupplier, responseHandler);
126+
}
127+
128+
@SuppressForbidden(reason = "We call connect in doPrivileged and provide SocketPermission")
129+
private HttpResponse execute(String method, URL url, String authorizationHeader,
130+
CheckedSupplier<String, Exception> requestBodySupplier,
131+
CheckedFunction<InputStream, HttpResponseBuilder, Exception> responseHandler) throws Exception {
86132
final HttpURLConnection conn;
87133
// If using SSL, need a custom service because it's likely a self-signed certificate
88134
if ("https".equalsIgnoreCase(url.getProtocol())) {
89135
final SSLService sslService = new SSLService(env);
90136
final HttpsURLConnection httpsConn = (HttpsURLConnection) url.openConnection();
91-
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
92-
final SslConfiguration sslConfiguration = sslService.getHttpTransportSSLConfiguration();
93-
// Requires permission java.lang.RuntimePermission "setFactory";
94-
httpsConn.setSSLSocketFactory(sslService.sslSocketFactory(sslConfiguration));
95-
final boolean isHostnameVerificationEnabled = sslConfiguration.getVerificationMode().isHostnameVerificationEnabled();
96-
if (isHostnameVerificationEnabled == false) {
97-
httpsConn.setHostnameVerifier((hostname, session) -> true);
137+
AccessController.doPrivileged((PrivilegedExceptionAction<Void>) () -> {
138+
if (pinnedCaCertFingerprint != null) {
139+
final SSLContext sslContext = SSLContext.getInstance("TLS");
140+
sslContext.init(null, new TrustManager[] { fingerprintTrustingTrustManager(pinnedCaCertFingerprint) }, null);
141+
httpsConn.setSSLSocketFactory(sslContext.getSocketFactory());
142+
} else {
143+
final SslConfiguration sslConfiguration = sslService.getHttpTransportSSLConfiguration();
144+
// Requires permission java.lang.RuntimePermission "setFactory";
145+
httpsConn.setSSLSocketFactory(sslService.sslSocketFactory(sslConfiguration));
146+
final boolean isHostnameVerificationEnabled = sslConfiguration.getVerificationMode().isHostnameVerificationEnabled();
147+
if (isHostnameVerificationEnabled == false) {
148+
httpsConn.setHostnameVerifier((hostname, session) -> true);
149+
}
98150
}
99151
return null;
100152
});
@@ -105,8 +157,7 @@ public HttpResponse execute(String method, URL url, String user, SecureString pa
105157
conn.setRequestMethod(method);
106158
conn.setReadTimeout(READ_TIMEOUT);
107159
// Add basic-auth header
108-
String token = UsernamePasswordToken.basicAuthHeaderValue(user, password);
109-
conn.setRequestProperty("Authorization", token);
160+
conn.setRequestProperty("Authorization", authorizationHeader);
110161
conn.setRequestProperty("Content-Type", XContentType.JSON.mediaType());
111162
String bodyString = requestBodySupplier.get();
112163
conn.setDoOutput(bodyString != null); // set true if we are sending a body
@@ -253,4 +304,48 @@ public static HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) t
253304
public static URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException {
254305
return new URL(url, (url.toURI().getPath() + path).replaceAll("/+", "/") + query);
255306
}
307+
308+
public static String apiKeyHeaderValue(SecureString apiKey) {
309+
CharBuffer chars = CharBuffer.allocate(apiKey.length());
310+
byte[] charBytes = null;
311+
try {
312+
chars.put(apiKey.getChars());
313+
charBytes = CharArrays.toUtf8Bytes(chars.array());
314+
315+
//TODO we still have passwords in Strings in headers. Maybe we can look into using a CharSequence?
316+
String apiKeyToken = Base64.getEncoder().encodeToString(charBytes);
317+
return "ApiKey " + apiKeyToken;
318+
} finally {
319+
Arrays.fill(chars.array(), (char) 0);
320+
if (charBytes != null) {
321+
Arrays.fill(charBytes, (byte) 0);
322+
}
323+
}
324+
}
325+
326+
/**
327+
* Returns a TrustManager to be used in a client SSLContext, which trusts all certificates that are signed
328+
* by a specific CA certificate ( identified by its SHA256 fingerprint, {@code pinnedCaCertFingerPrint} )
329+
*/
330+
private TrustManager fingerprintTrustingTrustManager(String pinnedCaCertFingerprint) {
331+
final TrustManager trustManager = new X509TrustManager() {
332+
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
333+
}
334+
335+
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
336+
final Certificate caCertFromChain = chain[1];
337+
MessageDigest sha256 = MessageDigests.sha256();
338+
sha256.update(caCertFromChain.getEncoded());
339+
if (MessageDigests.toHexString(sha256.digest()).equals(pinnedCaCertFingerprint) == false ) {
340+
throw new CertificateException();
341+
}
342+
}
343+
344+
@Override public X509Certificate[] getAcceptedIssuers() {
345+
return new X509Certificate[0];
346+
}
347+
};
348+
349+
return trustManager;
350+
}
256351
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security;
9+
10+
import org.elasticsearch.common.Strings;
11+
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
12+
import org.elasticsearch.common.xcontent.DeprecationHandler;
13+
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
14+
import org.elasticsearch.common.xcontent.ParseField;
15+
import org.elasticsearch.common.xcontent.XContentBuilder;
16+
import org.elasticsearch.common.xcontent.XContentParser;
17+
import org.elasticsearch.common.xcontent.json.JsonXContent;
18+
19+
import java.io.IOException;
20+
import java.nio.charset.StandardCharsets;
21+
import java.util.Base64;
22+
import java.util.List;
23+
import java.util.Objects;
24+
25+
import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
26+
27+
public class EnrollmentToken {
28+
private final String apiKey;
29+
private final String fingerprint;
30+
private final String version;
31+
private final List<String > boundAddress;
32+
33+
public String getApiKey() { return apiKey; }
34+
public String getFingerprint() { return fingerprint; }
35+
public String getVersion() { return version; }
36+
public List<String> getBoundAddress() { return boundAddress; }
37+
38+
private static final ParseField API_KEY = new ParseField("key");
39+
private static final ParseField FINGERPRINT = new ParseField("fgr");
40+
private static final ParseField VERSION = new ParseField("ver");
41+
private static final ParseField ADDRESS = new ParseField("adr");
42+
43+
@SuppressWarnings("unchecked")
44+
public static final ConstructingObjectParser<EnrollmentToken, Void> PARSER = new ConstructingObjectParser<>("enrollment_token", false,
45+
a -> new EnrollmentToken((String) a[0], (String) a[1], (String) a[2], (List<String>) a[3]));
46+
47+
static {
48+
PARSER.declareString(constructorArg(), API_KEY);
49+
PARSER.declareString(constructorArg(), FINGERPRINT);
50+
PARSER.declareString(constructorArg(), VERSION);
51+
PARSER.declareStringArray(constructorArg(), ADDRESS);
52+
}
53+
/**
54+
* Create an EnrollmentToken
55+
*
56+
* @param apiKey API Key credential in the form apiKeyId:ApiKeySecret to be used for enroll calls
57+
* @param fingerprint hex encoded SHA256 fingerprint of the HTTP CA cert
58+
* @param version node version number
59+
* @param boundAddress IP Addresses and port numbers for the interfaces where the Elasticsearch node is listening on
60+
*/
61+
public EnrollmentToken(String apiKey, String fingerprint, String version, List<String> boundAddress) {
62+
this.apiKey = Objects.requireNonNull(apiKey);
63+
this.fingerprint = Objects.requireNonNull(fingerprint);
64+
this.version = Objects.requireNonNull(version);
65+
this.boundAddress = Objects.requireNonNull(boundAddress);
66+
}
67+
68+
public String getRaw() throws Exception {
69+
final XContentBuilder builder = JsonXContent.contentBuilder();
70+
builder.startObject();
71+
builder.field("ver", version);
72+
builder.startArray("adr");
73+
for (String bound_address : boundAddress) {
74+
builder.value(bound_address);
75+
}
76+
builder.endArray();
77+
builder.field("fgr", fingerprint);
78+
builder.field("key", apiKey);
79+
builder.endObject();
80+
return Strings.toString(builder);
81+
}
82+
83+
public String getEncoded() throws Exception {
84+
final String jsonString = getRaw();
85+
return Base64.getUrlEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8));
86+
}
87+
88+
/**
89+
* Decodes and parses an enrollment token from its serialized form (created with {@link EnrollmentToken#getEncoded()}
90+
* @param encoded The Base64 encoded JSON representation of the enrollment token
91+
* @return the parsed EnrollmentToken
92+
* @throws IOException when failing to decode the serialized token
93+
*/
94+
public static EnrollmentToken decodeFromString(String encoded) throws IOException {
95+
if (Strings.isNullOrEmpty(encoded)) {
96+
throw new IOException("Cannot decode enrollment token from an empty string");
97+
}
98+
final XContentParser jsonParser = JsonXContent.jsonXContent.createParser(
99+
NamedXContentRegistry.EMPTY,
100+
DeprecationHandler.THROW_UNSUPPORTED_OPERATION,
101+
Base64.getDecoder().decode(encoded)
102+
);
103+
return EnrollmentToken.PARSER.parse(jsonParser, null);
104+
}
105+
106+
@Override
107+
public boolean equals(Object o) {
108+
if (this == o) return true;
109+
if (o == null || getClass() != o.getClass()) return false;
110+
EnrollmentToken that = (EnrollmentToken) o;
111+
return apiKey.equals(that.apiKey) && fingerprint.equals(that.fingerprint) && version.equals(that.version) && boundAddress.equals(
112+
that.boundAddress);
113+
}
114+
115+
@Override
116+
public int hashCode() {
117+
return Objects.hash(apiKey, fingerprint, version, boundAddress);
118+
}
119+
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/HttpResponse.java renamed to x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/HttpResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
package org.elasticsearch.xpack.security.tool;
7+
package org.elasticsearch.xpack.core.security;
88

99
import org.elasticsearch.ElasticsearchParseException;
1010
import org.elasticsearch.common.xcontent.XContentHelper;

x-pack/plugin/core/src/main/plugin-metadata/plugin-security.policy

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
grant {
2+
// CommandLineHttpClient
3+
permission java.lang.RuntimePermission "setFactory";
24
// bouncy castle
35
permission java.security.SecurityPermission "putProviderProperty.BC";
46

0 commit comments

Comments
 (0)