Skip to content

Commit cc2e580

Browse files
committed
Validate certificate identity from cross cluster creds
1 parent 87d866d commit cc2e580

File tree

8 files changed

+409
-102
lines changed

8 files changed

+409
-102
lines changed

x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java

Lines changed: 122 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
package org.elasticsearch.xpack.remotecluster;
99

1010
import io.netty.handler.codec.http.HttpMethod;
11-
1211
import org.elasticsearch.action.search.SearchResponse;
1312
import org.elasticsearch.client.Request;
1413
import org.elasticsearch.client.RequestOptions;
@@ -25,6 +24,7 @@
2524
import org.junit.rules.TestRule;
2625

2726
import java.io.IOException;
27+
import java.io.UncheckedIOException;
2828
import java.util.Arrays;
2929
import java.util.List;
3030
import java.util.Locale;
@@ -38,7 +38,7 @@
3838

3939
public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRemoteClusterSecurityTestCase {
4040

41-
private static final AtomicReference<Map<String, Object>> API_KEY_MAP_REF = new AtomicReference<>();
41+
private static final AtomicReference<Map<String, Object>> MY_REMOTE_API_KEY_MAP_REF = new AtomicReference<>();
4242

4343
static {
4444
fulfillingCluster = ElasticsearchCluster.local()
@@ -49,9 +49,14 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe
4949
.setting("xpack.security.remote_cluster_server.ssl.enabled", "true")
5050
.setting("xpack.security.remote_cluster_server.ssl.key", "remote-cluster.key")
5151
.setting("xpack.security.remote_cluster_server.ssl.certificate", "remote-cluster.crt")
52+
.setting("xpack.security.audit.enabled", "true")
53+
.setting(
54+
"xpack.security.audit.logfile.events.include",
55+
"[authentication_success, authentication_failed, access_denied, access_granted]"
56+
)
5257
.configFile("signing_ca.crt", Resource.fromClasspath("signing/root.crt"))
53-
.setting("cluster.remote.signing.certificate_authorities", "signing_ca.crt")
5458
.keystore("xpack.security.remote_cluster_server.ssl.secure_key_passphrase", "remote-cluster-password")
59+
.setting("logger.org.elasticsearch.xpack.security", "trace") // useful for human debugging
5560
.build();
5661

5762
queryCluster = ElasticsearchCluster.local()
@@ -60,22 +65,25 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe
6065
.setting("xpack.security.remote_cluster_client.ssl.enabled", "true")
6166
.setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt")
6267
.configFile("signing.crt", Resource.fromClasspath("signing/signing.crt"))
63-
.setting("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt")
6468
.configFile("signing.key", Resource.fromClasspath("signing/signing.key"))
65-
.setting("cluster.remote.my_remote_cluster.signing.key", "signing.key")
6669
.keystore("cluster.remote.my_remote_cluster.credentials", () -> {
67-
if (API_KEY_MAP_REF.get() == null) {
68-
final Map<String, Object> apiKeyMap = createCrossClusterAccessApiKey("""
70+
if (MY_REMOTE_API_KEY_MAP_REF.get() == null) {
71+
final var accessJson = """
6972
{
7073
"search": [
7174
{
7275
"names": ["index*", "not_found_index"]
7376
}
7477
]
75-
}""");
76-
API_KEY_MAP_REF.set(apiKeyMap);
78+
}""";
79+
MY_REMOTE_API_KEY_MAP_REF.set(
80+
createCrossClusterAccessApiKey(
81+
accessJson,
82+
randomFrom("CN=instance", "^CN=instance$", "(?i)^CN=instance$", "^CN=[A-Za-z0-9_]+$")
83+
)
84+
);
7785
}
78-
return (String) API_KEY_MAP_REF.get().get("encoded");
86+
return (String) MY_REMOTE_API_KEY_MAP_REF.get().get("encoded");
7987
})
8088
.keystore("cluster.remote.invalid_remote.credentials", randomEncodedApiKey())
8189
.build();
@@ -86,33 +94,102 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe
8694
public static TestRule clusterRule = RuleChain.outerRule(fulfillingCluster).around(queryCluster);
8795

8896
public void testCrossClusterSearchWithCrossClusterApiKeySigning() throws Exception {
89-
indexTestData();
90-
assertCrossClusterSearchSuccessfulWithResult();
97+
updateClusterSettings(
98+
Settings.builder()
99+
.put("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt")
100+
.put("cluster.remote.my_remote_cluster.signing.key", "signing.key")
101+
.build()
102+
);
91103

92-
// Change the CA to something that doesn't trust the signing cert
93104
updateClusterSettingsFulfillingCluster(
94-
Settings.builder().put("cluster.remote.signing.certificate_authorities", "transport-ca.crt").build()
105+
Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build()
95106
);
96-
assertCrossClusterAuthFail();
97107

98-
// Update settings on query cluster to ignore unavailable remotes
99-
updateClusterSettings(Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(true)).build());
108+
indexTestData();
100109

101-
assertCrossClusterSearchSuccessfulWithoutResult();
110+
// Make sure we can search if cert trusted
111+
{
112+
assertCrossClusterSearchSuccessfulWithResult();
113+
}
102114

103-
// TODO add test for certificate identity configured for API key but no signature provided (should 401)
115+
// Test CA that does not trust cert
116+
{
117+
// Change the CA to something that doesn't trust the signing cert
118+
updateClusterSettingsFulfillingCluster(
119+
Settings.builder().put("cluster.remote.signing.certificate_authorities", "transport-ca.crt").build()
120+
);
121+
assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [(");
122+
123+
// Change the CA to the default trust store
124+
updateClusterSettingsFulfillingCluster(Settings.builder().putNull("cluster.remote.signing.certificate_authorities").build());
125+
assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [(");
126+
127+
// Update settings on query cluster to ignore unavailable remotes
128+
updateClusterSettings(
129+
Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(true)).build()
130+
);
131+
assertCrossClusterSearchSuccessfulWithoutResult();
104132

105-
// TODO add test for certificate identity not configured for API key but signature provided (should 200)
133+
// Reset skip_unavailable
134+
updateClusterSettings(
135+
Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(false)).build()
136+
);
106137

107-
// TODO add test for certificate identity not configured for API key but wrong signature provided (should 401)
138+
// Reset ca cert
139+
updateClusterSettingsFulfillingCluster(
140+
Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build()
141+
);
142+
// Confirm reset was successful
143+
assertCrossClusterSearchSuccessfulWithResult();
144+
}
145+
146+
// Test no signature provided
147+
{
148+
updateClusterSettings(
149+
Settings.builder()
150+
.putNull("cluster.remote.my_remote_cluster.signing.certificate")
151+
.putNull("cluster.remote.my_remote_cluster.signing.key")
152+
.build()
153+
);
154+
assertCrossClusterAuthFail("Expected signature for cross cluster api key but no signature provided");
108155

109-
// TODO add test for certificate identity regex matching (should 200)
156+
// Reset
157+
updateClusterSettings(
158+
Settings.builder()
159+
.put("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt")
160+
.put("cluster.remote.my_remote_cluster.signing.key", "signing.key")
161+
.build()
162+
);
163+
}
164+
165+
// Test API key without certificate identity and send signature anyway
166+
{
167+
final var accessJson = """
168+
{
169+
"search": [
170+
{
171+
"names": ["index*", "not_found_index"]
172+
}
173+
]
174+
}""";
175+
MY_REMOTE_API_KEY_MAP_REF.set(createCrossClusterAccessApiKey(accessJson));
176+
assertCrossClusterSearchSuccessfulWithResult();
177+
178+
// Change the CA to the default trust store to make sure untrusted signature fails auth even if it's not required
179+
updateClusterSettingsFulfillingCluster(Settings.builder().putNull("cluster.remote.signing.certificate_authorities").build());
180+
assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [(");
181+
182+
// Reset
183+
updateClusterSettingsFulfillingCluster(
184+
Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build()
185+
);
186+
}
110187
}
111188

112-
private void assertCrossClusterAuthFail() {
189+
private void assertCrossClusterAuthFail(String expectedMessage) {
113190
var responseException = assertThrows(ResponseException.class, () -> simpleCrossClusterSearch(randomBoolean()));
114191
assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(401));
115-
assertThat(responseException.getMessage(), containsString("Failed to verify cross cluster api key signature certificate from [("));
192+
assertThat(responseException.getMessage(), containsString(expectedMessage));
116193
}
117194

118195
private void assertCrossClusterSearchSuccessfulWithoutResult() throws IOException {
@@ -227,4 +304,25 @@ private Response performRequestWithRemoteAccessUser(final Request request) throw
227304
request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", basicAuthHeaderValue(REMOTE_SEARCH_USER, PASS)));
228305
return client().performRequest(request);
229306
}
307+
308+
protected static Map<String, Object> createCrossClusterAccessApiKey(String accessJson, String certificateIdentity) {
309+
initFulfillingClusterClient();
310+
final var createCrossClusterApiKeyRequest = new Request("POST", "/_security/cross_cluster/api_key");
311+
createCrossClusterApiKeyRequest.setJsonEntity(Strings.format("""
312+
{
313+
"name": "cross_cluster_access_key",
314+
"certificate_identity": "%s",
315+
"access": %s
316+
}""", certificateIdentity, accessJson));
317+
try {
318+
final Response createCrossClusterApiKeyResponse = performRequestWithAdminUser(
319+
fulfillingClusterClient,
320+
createCrossClusterApiKeyRequest
321+
);
322+
assertOK(createCrossClusterApiKeyResponse);
323+
return responseAsMap(createCrossClusterApiKeyResponse);
324+
} catch (IOException e) {
325+
throw new UncheckedIOException(e);
326+
}
327+
}
230328
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
import java.util.function.Consumer;
142142
import java.util.function.Function;
143143
import java.util.function.Supplier;
144+
import java.util.regex.Pattern;
144145
import java.util.stream.Collectors;
145146

146147
import static org.elasticsearch.common.SecureRandomUtils.getBase64SecureRandomString;
@@ -1396,7 +1397,7 @@ void validateApiKeyCredentials(
13961397
if (result.success) {
13971398
if (result.verify(credentials.getKey())) {
13981399
// move on
1399-
validateApiKeyTypeAndExpiration(apiKeyDoc, credentials, clock, listener);
1400+
completeApiKeyAuthentication(apiKeyDoc, credentials, clock, listener);
14001401
} else {
14011402
listener.onResponse(
14021403
AuthenticationResult.unsuccessful("invalid credentials for API key [" + credentials.getId() + "]", null)
@@ -1416,7 +1417,7 @@ void validateApiKeyCredentials(
14161417
listenableCacheEntry.onResponse(new CachedApiKeyHashResult(verified, credentials.getKey()));
14171418
if (verified) {
14181419
// move on
1419-
validateApiKeyTypeAndExpiration(apiKeyDoc, credentials, clock, listener);
1420+
completeApiKeyAuthentication(apiKeyDoc, credentials, clock, listener);
14201421
} else {
14211422
listener.onResponse(
14221423
AuthenticationResult.unsuccessful("invalid credentials for API key [" + credentials.getId() + "]", null)
@@ -1439,7 +1440,7 @@ void validateApiKeyCredentials(
14391440
verifyKeyAgainstHash(apiKeyDoc.hash, credentials, ActionListener.wrap(verified -> {
14401441
if (verified) {
14411442
// move on
1442-
validateApiKeyTypeAndExpiration(apiKeyDoc, credentials, clock, listener);
1443+
completeApiKeyAuthentication(apiKeyDoc, credentials, clock, listener);
14431444
} else {
14441445
listener.onResponse(
14451446
AuthenticationResult.unsuccessful("invalid credentials for API key [" + credentials.getId() + "]", null)
@@ -1471,7 +1472,7 @@ Cache<String, BytesReference> getRoleDescriptorsBytesCache() {
14711472
}
14721473

14731474
// package-private for testing
1474-
static void validateApiKeyTypeAndExpiration(
1475+
static void completeApiKeyAuthentication(
14751476
ApiKeyDoc apiKeyDoc,
14761477
ApiKeyCredentials credentials,
14771478
Clock clock,
@@ -1491,6 +1492,27 @@ static void validateApiKeyTypeAndExpiration(
14911492
return;
14921493
}
14931494

1495+
if (apiKeyDoc.certificateIdentity != null) {
1496+
if (credentials.getCertificateIdentity() == null) {
1497+
listener.onResponse(
1498+
AuthenticationResult.terminate("Expected signature for cross cluster api key but no signature provided")
1499+
);
1500+
return;
1501+
}
1502+
if (validateCertificateIdentity(credentials.getCertificateIdentity(), apiKeyDoc.certificateIdentity) == false) {
1503+
listener.onResponse(
1504+
AuthenticationResult.terminate(
1505+
Strings.format(
1506+
"DN from provided certificate [%s] does no match API Key certificate identity pattern [%s]",
1507+
credentials.getCertificateIdentity(),
1508+
apiKeyDoc.certificateIdentity
1509+
)
1510+
)
1511+
);
1512+
return;
1513+
}
1514+
}
1515+
14941516
if (apiKeyDoc.expirationTime == -1 || Instant.ofEpochMilli(apiKeyDoc.expirationTime).isAfter(clock.instant())) {
14951517
final String principal = Objects.requireNonNull((String) apiKeyDoc.creator.get("principal"));
14961518
final String fullName = (String) apiKeyDoc.creator.get("full_name");
@@ -1515,22 +1537,31 @@ static void validateApiKeyTypeAndExpiration(
15151537
}
15161538
}
15171539

1540+
private static boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) {
1541+
logger.trace("Validating certificate identity [{}] against [{}]", certificateIdentity, certificateIdentityPattern);
1542+
return Pattern.compile(certificateIdentityPattern).matcher(certificateIdentity).matches();
1543+
}
1544+
15181545
ApiKeyCredentials parseCredentialsFromApiKeyString(SecureString apiKeyString) {
15191546
if (false == isEnabled()) {
15201547
return null;
15211548
}
1522-
return parseApiKey(apiKeyString, ApiKey.Type.REST);
1549+
return parseApiKey(apiKeyString, null, ApiKey.Type.REST);
15231550
}
15241551

1525-
static ApiKeyCredentials getCredentialsFromHeader(final String header, ApiKey.Type expectedType) {
1526-
return parseApiKey(Authenticator.extractCredentialFromHeaderValue(header, "ApiKey"), expectedType);
1552+
static ApiKeyCredentials getCredentialsFromHeader(final String header, @Nullable String certificateIdentity, ApiKey.Type expectedType) {
1553+
return parseApiKey(Authenticator.extractCredentialFromHeaderValue(header, "ApiKey"), certificateIdentity, expectedType);
15271554
}
15281555

15291556
public static String withApiKeyPrefix(final String encodedApiKey) {
15301557
return "ApiKey " + encodedApiKey;
15311558
}
15321559

1533-
private static ApiKeyCredentials parseApiKey(SecureString apiKeyString, ApiKey.Type expectedType) {
1560+
private static ApiKeyCredentials parseApiKey(
1561+
SecureString apiKeyString,
1562+
@Nullable String certificateIdentity,
1563+
ApiKey.Type expectedType
1564+
) {
15341565
if (apiKeyString != null) {
15351566
final byte[] decodedApiKeyCredBytes = Base64.getDecoder().decode(CharArrays.toUtf8Bytes(apiKeyString.getChars()));
15361567
char[] apiKeyCredChars = null;
@@ -1554,7 +1585,8 @@ private static ApiKeyCredentials parseApiKey(SecureString apiKeyString, ApiKey.T
15541585
return new ApiKeyCredentials(
15551586
new String(Arrays.copyOfRange(apiKeyCredChars, 0, colonIndex)),
15561587
new SecureString(Arrays.copyOfRange(apiKeyCredChars, secretStartPos, apiKeyCredChars.length)),
1557-
expectedType
1588+
expectedType,
1589+
certificateIdentity
15581590
);
15591591
} finally {
15601592
if (apiKeyCredChars != null) {
@@ -1671,11 +1703,17 @@ public static final class ApiKeyCredentials implements AuthenticationToken, Clos
16711703
private final String id;
16721704
private final SecureString key;
16731705
private final ApiKey.Type expectedType;
1706+
private final String certificateIdentity;
16741707

16751708
public ApiKeyCredentials(String id, SecureString key, ApiKey.Type expectedType) {
1709+
this(id, key, expectedType, null);
1710+
}
1711+
1712+
public ApiKeyCredentials(String id, SecureString key, ApiKey.Type expectedType, @Nullable String certificateIdentity) {
16761713
this.id = id;
16771714
this.key = key;
16781715
this.expectedType = expectedType;
1716+
this.certificateIdentity = certificateIdentity;
16791717
}
16801718

16811719
String getId() {
@@ -1709,6 +1747,10 @@ public void clearCredentials() {
17091747
public ApiKey.Type getExpectedType() {
17101748
return expectedType;
17111749
}
1750+
1751+
public String getCertificateIdentity() {
1752+
return certificateIdentity;
1753+
}
17121754
}
17131755

17141756
private static class ApiKeyLoggingDeprecationHandler implements DeprecationHandler {

0 commit comments

Comments
 (0)