Skip to content

Commit 99fac10

Browse files
committed
Validate certificate identity from cross cluster creds
1 parent 90db8f2 commit 99fac10

File tree

8 files changed

+412
-111
lines changed

8 files changed

+412
-111
lines changed

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

Lines changed: 121 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,8 +49,12 @@ 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")
5559
.build();
5660

@@ -60,22 +64,25 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe
6064
.setting("xpack.security.remote_cluster_client.ssl.enabled", "true")
6165
.setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt")
6266
.configFile("signing.crt", Resource.fromClasspath("signing/signing.crt"))
63-
.setting("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt")
6467
.configFile("signing.key", Resource.fromClasspath("signing/signing.key"))
65-
.setting("cluster.remote.my_remote_cluster.signing.key", "signing.key")
6668
.keystore("cluster.remote.my_remote_cluster.credentials", () -> {
67-
if (API_KEY_MAP_REF.get() == null) {
68-
final Map<String, Object> apiKeyMap = createCrossClusterAccessApiKey("""
69+
if (MY_REMOTE_API_KEY_MAP_REF.get() == null) {
70+
final var accessJson = """
6971
{
7072
"search": [
7173
{
7274
"names": ["index*", "not_found_index"]
7375
}
7476
]
75-
}""");
76-
API_KEY_MAP_REF.set(apiKeyMap);
77+
}""";
78+
MY_REMOTE_API_KEY_MAP_REF.set(
79+
createCrossClusterAccessApiKey(
80+
accessJson,
81+
randomFrom("CN=instance", "^CN=instance$", "(?i)^CN=instance$", "^CN=[A-Za-z0-9_]+$")
82+
)
83+
);
7784
}
78-
return (String) API_KEY_MAP_REF.get().get("encoded");
85+
return (String) MY_REMOTE_API_KEY_MAP_REF.get().get("encoded");
7986
})
8087
.keystore("cluster.remote.invalid_remote.credentials", randomEncodedApiKey())
8188
.build();
@@ -86,33 +93,102 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe
8693
public static TestRule clusterRule = RuleChain.outerRule(fulfillingCluster).around(queryCluster);
8794

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

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

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());
107+
indexTestData();
100108

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

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

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

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

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

112-
private void assertCrossClusterAuthFail() {
188+
private void assertCrossClusterAuthFail(String expectedMessage) {
113189
var responseException = assertThrows(ResponseException.class, () -> simpleCrossClusterSearch(randomBoolean()));
114190
assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(401));
115-
assertThat(responseException.getMessage(), containsString("Failed to verify cross cluster api key signature certificate from [("));
191+
assertThat(responseException.getMessage(), containsString(expectedMessage));
116192
}
117193

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

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

Lines changed: 52 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 was 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 not 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,32 @@ 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+
// Consider adding a cache if this causes performance problems
1543+
return Pattern.compile(certificateIdentityPattern).matcher(certificateIdentity).matches();
1544+
}
1545+
15181546
ApiKeyCredentials parseCredentialsFromApiKeyString(SecureString apiKeyString) {
15191547
if (false == isEnabled()) {
15201548
return null;
15211549
}
1522-
return parseApiKey(apiKeyString, ApiKey.Type.REST);
1550+
return parseApiKey(apiKeyString, null, ApiKey.Type.REST);
15231551
}
15241552

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

15291557
public static String withApiKeyPrefix(final String encodedApiKey) {
15301558
return "ApiKey " + encodedApiKey;
15311559
}
15321560

1533-
private static ApiKeyCredentials parseApiKey(SecureString apiKeyString, ApiKey.Type expectedType) {
1561+
private static ApiKeyCredentials parseApiKey(
1562+
SecureString apiKeyString,
1563+
@Nullable String certificateIdentity,
1564+
ApiKey.Type expectedType
1565+
) {
15341566
if (apiKeyString != null) {
15351567
final byte[] decodedApiKeyCredBytes = Base64.getDecoder().decode(CharArrays.toUtf8Bytes(apiKeyString.getChars()));
15361568
char[] apiKeyCredChars = null;
@@ -1554,7 +1586,8 @@ private static ApiKeyCredentials parseApiKey(SecureString apiKeyString, ApiKey.T
15541586
return new ApiKeyCredentials(
15551587
new String(Arrays.copyOfRange(apiKeyCredChars, 0, colonIndex)),
15561588
new SecureString(Arrays.copyOfRange(apiKeyCredChars, secretStartPos, apiKeyCredChars.length)),
1557-
expectedType
1589+
expectedType,
1590+
certificateIdentity
15581591
);
15591592
} finally {
15601593
if (apiKeyCredChars != null) {
@@ -1671,11 +1704,17 @@ public static final class ApiKeyCredentials implements AuthenticationToken, Clos
16711704
private final String id;
16721705
private final SecureString key;
16731706
private final ApiKey.Type expectedType;
1707+
private final String certificateIdentity;
16741708

16751709
public ApiKeyCredentials(String id, SecureString key, ApiKey.Type expectedType) {
1710+
this(id, key, expectedType, null);
1711+
}
1712+
1713+
public ApiKeyCredentials(String id, SecureString key, ApiKey.Type expectedType, @Nullable String certificateIdentity) {
16761714
this.id = id;
16771715
this.key = key;
16781716
this.expectedType = expectedType;
1717+
this.certificateIdentity = certificateIdentity;
16791718
}
16801719

16811720
String getId() {
@@ -1709,6 +1748,10 @@ public void clearCredentials() {
17091748
public ApiKey.Type getExpectedType() {
17101749
return expectedType;
17111750
}
1751+
1752+
public String getCertificateIdentity() {
1753+
return certificateIdentity;
1754+
}
17121755
}
17131756

17141757
private static class ApiKeyLoggingDeprecationHandler implements DeprecationHandler {

0 commit comments

Comments
 (0)