From 99fac109df2d701051b5de474c66a0f992134183 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Tue, 7 Oct 2025 16:00:21 +0200 Subject: [PATCH 01/14] Validate certificate identity from cross cluster creds --- ...erSecurityCrossClusterApiKeySigningIT.java | 145 +++++++++++++++--- .../xpack/security/authc/ApiKeyService.java | 61 ++++++-- ...ossClusterAccessAuthenticationService.java | 29 ++-- .../authc/CrossClusterAccessHeaders.java | 23 ++- .../CrossClusterApiKeySignatureManager.java | 12 +- ...ossClusterApiKeySigningConfigReloader.java | 1 - .../security/authc/ApiKeyServiceTests.java | 127 +++++++++------ ...AccessAuthenticationServiceIntegTests.java | 125 +++++++++++++-- 8 files changed, 412 insertions(+), 111 deletions(-) diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java index 446a6c546b509..e2ad0f67a3bbf 100644 --- a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java @@ -8,7 +8,6 @@ package org.elasticsearch.xpack.remotecluster; import io.netty.handler.codec.http.HttpMethod; - import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.client.Request; import org.elasticsearch.client.RequestOptions; @@ -25,6 +24,7 @@ import org.junit.rules.TestRule; import java.io.IOException; +import java.io.UncheckedIOException; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -38,7 +38,7 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRemoteClusterSecurityTestCase { - private static final AtomicReference> API_KEY_MAP_REF = new AtomicReference<>(); + private static final AtomicReference> MY_REMOTE_API_KEY_MAP_REF = new AtomicReference<>(); static { fulfillingCluster = ElasticsearchCluster.local() @@ -49,8 +49,12 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe .setting("xpack.security.remote_cluster_server.ssl.enabled", "true") .setting("xpack.security.remote_cluster_server.ssl.key", "remote-cluster.key") .setting("xpack.security.remote_cluster_server.ssl.certificate", "remote-cluster.crt") + .setting("xpack.security.audit.enabled", "true") + .setting( + "xpack.security.audit.logfile.events.include", + "[authentication_success, authentication_failed, access_denied, access_granted]" + ) .configFile("signing_ca.crt", Resource.fromClasspath("signing/root.crt")) - .setting("cluster.remote.signing.certificate_authorities", "signing_ca.crt") .keystore("xpack.security.remote_cluster_server.ssl.secure_key_passphrase", "remote-cluster-password") .build(); @@ -60,22 +64,25 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe .setting("xpack.security.remote_cluster_client.ssl.enabled", "true") .setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt") .configFile("signing.crt", Resource.fromClasspath("signing/signing.crt")) - .setting("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt") .configFile("signing.key", Resource.fromClasspath("signing/signing.key")) - .setting("cluster.remote.my_remote_cluster.signing.key", "signing.key") .keystore("cluster.remote.my_remote_cluster.credentials", () -> { - if (API_KEY_MAP_REF.get() == null) { - final Map apiKeyMap = createCrossClusterAccessApiKey(""" + if (MY_REMOTE_API_KEY_MAP_REF.get() == null) { + final var accessJson = """ { "search": [ { "names": ["index*", "not_found_index"] } ] - }"""); - API_KEY_MAP_REF.set(apiKeyMap); + }"""; + MY_REMOTE_API_KEY_MAP_REF.set( + createCrossClusterAccessApiKey( + accessJson, + randomFrom("CN=instance", "^CN=instance$", "(?i)^CN=instance$", "^CN=[A-Za-z0-9_]+$") + ) + ); } - return (String) API_KEY_MAP_REF.get().get("encoded"); + return (String) MY_REMOTE_API_KEY_MAP_REF.get().get("encoded"); }) .keystore("cluster.remote.invalid_remote.credentials", randomEncodedApiKey()) .build(); @@ -86,33 +93,102 @@ public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRe public static TestRule clusterRule = RuleChain.outerRule(fulfillingCluster).around(queryCluster); public void testCrossClusterSearchWithCrossClusterApiKeySigning() throws Exception { - indexTestData(); - assertCrossClusterSearchSuccessfulWithResult(); + updateClusterSettings( + Settings.builder() + .put("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt") + .put("cluster.remote.my_remote_cluster.signing.key", "signing.key") + .build() + ); - // Change the CA to something that doesn't trust the signing cert updateClusterSettingsFulfillingCluster( - Settings.builder().put("cluster.remote.signing.certificate_authorities", "transport-ca.crt").build() + Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build() ); - assertCrossClusterAuthFail(); - // Update settings on query cluster to ignore unavailable remotes - updateClusterSettings(Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(true)).build()); + indexTestData(); - assertCrossClusterSearchSuccessfulWithoutResult(); + // Make sure we can search if cert trusted + { + assertCrossClusterSearchSuccessfulWithResult(); + } - // TODO add test for certificate identity configured for API key but no signature provided (should 401) + // Test CA that does not trust cert + { + // Change the CA to something that doesn't trust the signing cert + updateClusterSettingsFulfillingCluster( + Settings.builder().put("cluster.remote.signing.certificate_authorities", "transport-ca.crt").build() + ); + assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [("); + + // Change the CA to the default trust store + updateClusterSettingsFulfillingCluster(Settings.builder().putNull("cluster.remote.signing.certificate_authorities").build()); + assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [("); + + // Update settings on query cluster to ignore unavailable remotes + updateClusterSettings( + Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(true)).build() + ); + assertCrossClusterSearchSuccessfulWithoutResult(); - // TODO add test for certificate identity not configured for API key but signature provided (should 200) + // Reset skip_unavailable + updateClusterSettings( + Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(false)).build() + ); - // TODO add test for certificate identity not configured for API key but wrong signature provided (should 401) + // Reset ca cert + updateClusterSettingsFulfillingCluster( + Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build() + ); + // Confirm reset was successful + assertCrossClusterSearchSuccessfulWithResult(); + } + + // Test no signature provided + { + updateClusterSettings( + Settings.builder() + .putNull("cluster.remote.my_remote_cluster.signing.certificate") + .putNull("cluster.remote.my_remote_cluster.signing.key") + .build() + ); + assertCrossClusterAuthFail("Expected signature for cross cluster API key, but no signature was provided"); - // TODO add test for certificate identity regex matching (should 200) + // Reset + updateClusterSettings( + Settings.builder() + .put("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt") + .put("cluster.remote.my_remote_cluster.signing.key", "signing.key") + .build() + ); + } + + // Test API key without certificate identity and send signature anyway + { + final var accessJson = """ + { + "search": [ + { + "names": ["index*", "not_found_index"] + } + ] + }"""; + MY_REMOTE_API_KEY_MAP_REF.set(createCrossClusterAccessApiKey(accessJson)); + assertCrossClusterSearchSuccessfulWithResult(); + + // Change the CA to the default trust store to make sure untrusted signature fails auth even if it's not required + updateClusterSettingsFulfillingCluster(Settings.builder().putNull("cluster.remote.signing.certificate_authorities").build()); + assertCrossClusterAuthFail("Failed to verify cross cluster api key signature certificate from [("); + + // Reset + updateClusterSettingsFulfillingCluster( + Settings.builder().put("cluster.remote.signing.certificate_authorities", "signing_ca.crt").build() + ); + } } - private void assertCrossClusterAuthFail() { + private void assertCrossClusterAuthFail(String expectedMessage) { var responseException = assertThrows(ResponseException.class, () -> simpleCrossClusterSearch(randomBoolean())); assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(401)); - assertThat(responseException.getMessage(), containsString("Failed to verify cross cluster api key signature certificate from [(")); + assertThat(responseException.getMessage(), containsString(expectedMessage)); } private void assertCrossClusterSearchSuccessfulWithoutResult() throws IOException { @@ -227,4 +303,25 @@ private Response performRequestWithRemoteAccessUser(final Request request) throw request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", basicAuthHeaderValue(REMOTE_SEARCH_USER, PASS))); return client().performRequest(request); } + + protected static Map createCrossClusterAccessApiKey(String accessJson, String certificateIdentity) { + initFulfillingClusterClient(); + final var createCrossClusterApiKeyRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createCrossClusterApiKeyRequest.setJsonEntity(Strings.format(""" + { + "name": "cross_cluster_access_key", + "certificate_identity": "%s", + "access": %s + }""", certificateIdentity, accessJson)); + try { + final Response createCrossClusterApiKeyResponse = performRequestWithAdminUser( + fulfillingClusterClient, + createCrossClusterApiKeyRequest + ); + assertOK(createCrossClusterApiKeyResponse); + return responseAsMap(createCrossClusterApiKeyResponse); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index c0d00c3838597..234ab14470ff2 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -141,6 +141,7 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.elasticsearch.common.SecureRandomUtils.getBase64SecureRandomString; @@ -1396,7 +1397,7 @@ void validateApiKeyCredentials( if (result.success) { if (result.verify(credentials.getKey())) { // move on - validateApiKeyTypeAndExpiration(apiKeyDoc, credentials, clock, listener); + completeApiKeyAuthentication(apiKeyDoc, credentials, clock, listener); } else { listener.onResponse( AuthenticationResult.unsuccessful("invalid credentials for API key [" + credentials.getId() + "]", null) @@ -1416,7 +1417,7 @@ void validateApiKeyCredentials( listenableCacheEntry.onResponse(new CachedApiKeyHashResult(verified, credentials.getKey())); if (verified) { // move on - validateApiKeyTypeAndExpiration(apiKeyDoc, credentials, clock, listener); + completeApiKeyAuthentication(apiKeyDoc, credentials, clock, listener); } else { listener.onResponse( AuthenticationResult.unsuccessful("invalid credentials for API key [" + credentials.getId() + "]", null) @@ -1439,7 +1440,7 @@ void validateApiKeyCredentials( verifyKeyAgainstHash(apiKeyDoc.hash, credentials, ActionListener.wrap(verified -> { if (verified) { // move on - validateApiKeyTypeAndExpiration(apiKeyDoc, credentials, clock, listener); + completeApiKeyAuthentication(apiKeyDoc, credentials, clock, listener); } else { listener.onResponse( AuthenticationResult.unsuccessful("invalid credentials for API key [" + credentials.getId() + "]", null) @@ -1471,7 +1472,7 @@ Cache getRoleDescriptorsBytesCache() { } // package-private for testing - static void validateApiKeyTypeAndExpiration( + static void completeApiKeyAuthentication( ApiKeyDoc apiKeyDoc, ApiKeyCredentials credentials, Clock clock, @@ -1491,6 +1492,27 @@ static void validateApiKeyTypeAndExpiration( return; } + if (apiKeyDoc.certificateIdentity != null) { + if (credentials.getCertificateIdentity() == null) { + listener.onResponse( + AuthenticationResult.terminate("Expected signature for cross cluster API key, but no signature was provided") + ); + return; + } + if (validateCertificateIdentity(credentials.getCertificateIdentity(), apiKeyDoc.certificateIdentity) == false) { + listener.onResponse( + AuthenticationResult.terminate( + Strings.format( + "DN from provided certificate [%s] does not match API Key certificate identity pattern [%s]", + credentials.getCertificateIdentity(), + apiKeyDoc.certificateIdentity + ) + ) + ); + return; + } + } + if (apiKeyDoc.expirationTime == -1 || Instant.ofEpochMilli(apiKeyDoc.expirationTime).isAfter(clock.instant())) { final String principal = Objects.requireNonNull((String) apiKeyDoc.creator.get("principal")); final String fullName = (String) apiKeyDoc.creator.get("full_name"); @@ -1515,22 +1537,32 @@ static void validateApiKeyTypeAndExpiration( } } + private static boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) { + logger.trace("Validating certificate identity [{}] against [{}]", certificateIdentity, certificateIdentityPattern); + // Consider adding a cache if this causes performance problems + return Pattern.compile(certificateIdentityPattern).matcher(certificateIdentity).matches(); + } + ApiKeyCredentials parseCredentialsFromApiKeyString(SecureString apiKeyString) { if (false == isEnabled()) { return null; } - return parseApiKey(apiKeyString, ApiKey.Type.REST); + return parseApiKey(apiKeyString, null, ApiKey.Type.REST); } - static ApiKeyCredentials getCredentialsFromHeader(final String header, ApiKey.Type expectedType) { - return parseApiKey(Authenticator.extractCredentialFromHeaderValue(header, "ApiKey"), expectedType); + static ApiKeyCredentials getCredentialsFromHeader(final String header, @Nullable String certificateIdentity, ApiKey.Type expectedType) { + return parseApiKey(Authenticator.extractCredentialFromHeaderValue(header, "ApiKey"), certificateIdentity, expectedType); } public static String withApiKeyPrefix(final String encodedApiKey) { return "ApiKey " + encodedApiKey; } - private static ApiKeyCredentials parseApiKey(SecureString apiKeyString, ApiKey.Type expectedType) { + private static ApiKeyCredentials parseApiKey( + SecureString apiKeyString, + @Nullable String certificateIdentity, + ApiKey.Type expectedType + ) { if (apiKeyString != null) { final byte[] decodedApiKeyCredBytes = Base64.getDecoder().decode(CharArrays.toUtf8Bytes(apiKeyString.getChars())); char[] apiKeyCredChars = null; @@ -1554,7 +1586,8 @@ private static ApiKeyCredentials parseApiKey(SecureString apiKeyString, ApiKey.T return new ApiKeyCredentials( new String(Arrays.copyOfRange(apiKeyCredChars, 0, colonIndex)), new SecureString(Arrays.copyOfRange(apiKeyCredChars, secretStartPos, apiKeyCredChars.length)), - expectedType + expectedType, + certificateIdentity ); } finally { if (apiKeyCredChars != null) { @@ -1671,11 +1704,17 @@ public static final class ApiKeyCredentials implements AuthenticationToken, Clos private final String id; private final SecureString key; private final ApiKey.Type expectedType; + private final String certificateIdentity; public ApiKeyCredentials(String id, SecureString key, ApiKey.Type expectedType) { + this(id, key, expectedType, null); + } + + public ApiKeyCredentials(String id, SecureString key, ApiKey.Type expectedType, @Nullable String certificateIdentity) { this.id = id; this.key = key; this.expectedType = expectedType; + this.certificateIdentity = certificateIdentity; } String getId() { @@ -1709,6 +1748,10 @@ public void clearCredentials() { public ApiKey.Type getExpectedType() { return expectedType; } + + public String getCertificateIdentity() { + return certificateIdentity; + } } private static class ApiKeyLoggingDeprecationHandler implements DeprecationHandler { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java index e5a2b7a53a817..63075f1337dba 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java @@ -37,6 +37,8 @@ import static org.elasticsearch.transport.RemoteClusterPortSettings.TRANSPORT_VERSION_ADVANCED_REMOTE_CLUSTER_SECURITY; import static org.elasticsearch.xpack.core.security.authc.CrossClusterAccessSubjectInfo.CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY; import static org.elasticsearch.xpack.security.authc.CrossClusterAccessHeaders.CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY; +import static org.elasticsearch.xpack.security.authc.CrossClusterAccessHeaders.getCertificateIdentity; +import static org.elasticsearch.xpack.security.transport.X509CertificateSignature.CROSS_CLUSTER_ACCESS_SIGNATURE_HEADER_KEY; public class CrossClusterAccessAuthenticationService implements RemoteClusterAuthenticationService { @@ -71,18 +73,17 @@ public void authenticate(final String action, final TransportRequest request, fi assert ApiKey.Type.CROSS_CLUSTER == apiKeyCredentials.getExpectedType(); // authn must verify only the provided api key and not try to extract any other credential from the thread context authcContext = authenticationService.newContext(action, request, apiKeyCredentials); + var signature = crossClusterAccessHeaders.signature(); + + // Always validate a signature if provided + if (signature != null && verifySignature(authcContext, signature, crossClusterAccessHeaders, listener) == false) { + return; + } } catch (Exception ex) { withRequestProcessingFailure(authenticationService.newContext(action, request, null), ex, listener); return; } - // TODO ALWAYS check if used api key has a certificate identity and do this verification conditionally based on that - var signature = crossClusterAccessHeaders.signature(); - // Always validate a signature if provided - if (signature != null && verifySignature(authcContext, signature, crossClusterAccessHeaders, listener) == false) { - return; - } - try { apiKeyService.ensureEnabled(); } catch (Exception ex) { @@ -120,7 +121,6 @@ public void authenticate(final String action, final TransportRequest request, fi new ContextPreservingActionListener<>(storedContextSupplier, ActionListener.wrap(authentication -> { assert authentication.isApiKey() : "initial authentication for cross cluster access must be by API key"; assert false == authentication.isRunAs() : "initial authentication for cross cluster access cannot be run-as"; - // try-catch so any failure here is wrapped by `withRequestProcessingFailure`, whereas `authenticate` failures are not // we should _not_ wrap `authenticate` failures since this produces duplicate audit events try { @@ -158,7 +158,7 @@ private boolean verifySignature( ); } if (authException != null) { - // TODO Verify this covers all audit logging scenarios + // TODO handle audit logging listener.onFailure(context.getRequest().exceptionProcessingRequest(authException, context.getMostRecentAuthenticationToken())); return false; } @@ -186,7 +186,7 @@ void tryAuthenticate(ApiKeyService.ApiKeyCredentials credentials, ActionListener listener.onResponse(null); return; } - + // TODO handle audit logging if (authResult.getStatus() == AuthenticationResult.Status.TERMINATE) { Exception e = (authResult.getException() != null) ? authResult.getException() @@ -216,7 +216,14 @@ public ApiKeyService.ApiKeyCredentials extractApiKeyCredentialsFromHeaders(Map ApiKeyService.getCredentialsFromHeader( "ApiKey " + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)), + null, ApiKey.Type.CROSS_CLUSTER ) ); @@ -3079,7 +3080,7 @@ public void testAuthenticationFailureWithApiKeyTypeMismatch() throws Exception { assertThat(service.getApiKeyAuthCache().keys(), contains(id)); } - public void testValidateApiKeyTypeAndExpiration() throws IOException { + public void testCompleteApiKeyAuthentication() throws IOException { final var apiKeyId = randomAlphaOfLength(12); final var apiKey = randomAlphaOfLength(16); final var hasher = getFastStoredHashAlgoForTests(); @@ -3100,7 +3101,7 @@ public void testValidateApiKeyTypeAndExpiration() throws IOException { final ApiKey.Type expectedType1 = randomValueOtherThan(apiKeyDoc1.type, () -> randomFrom(ApiKey.Type.values())); final ApiKeyCredentials apiKeyCredentials1 = getApiKeyCredentials(apiKeyId, apiKey, expectedType1); final PlainActionFuture> future1 = new PlainActionFuture<>(); - ApiKeyService.validateApiKeyTypeAndExpiration(apiKeyDoc1, apiKeyCredentials1, clock, future1); + ApiKeyService.completeApiKeyAuthentication(apiKeyDoc1, apiKeyCredentials1, clock, future1); final AuthenticationResult auth1 = future1.actionGet(); assertThat(auth1.getStatus(), is(AuthenticationResult.Status.TERMINATE)); assertThat(auth1.getValue(), nullValue()); @@ -3121,7 +3122,7 @@ public void testValidateApiKeyTypeAndExpiration() throws IOException { final var apiKeyDoc2 = buildApiKeyDoc(hash, pastTime, false, -1, randomAlphaOfLengthBetween(3, 8), Version.CURRENT.id); final ApiKeyCredentials apiKeyCredentials2 = getApiKeyCredentials(apiKeyId, apiKey, apiKeyDoc2.type); final PlainActionFuture> future2 = new PlainActionFuture<>(); - ApiKeyService.validateApiKeyTypeAndExpiration(apiKeyDoc2, apiKeyCredentials2, clock, future2); + ApiKeyService.completeApiKeyAuthentication(apiKeyDoc2, apiKeyCredentials2, clock, future2); final AuthenticationResult auth2 = future2.actionGet(); assertThat(auth2.getStatus(), is(AuthenticationResult.Status.CONTINUE)); assertThat(auth2.getValue(), nullValue()); @@ -3138,7 +3139,7 @@ public void testValidateApiKeyTypeAndExpiration() throws IOException { ); final ApiKeyCredentials apiKeyCredentials3 = getApiKeyCredentials(apiKeyId, apiKey, apiKeyDoc3.type); final PlainActionFuture> future3 = new PlainActionFuture<>(); - ApiKeyService.validateApiKeyTypeAndExpiration(apiKeyDoc3, apiKeyCredentials3, clock, future3); + ApiKeyService.completeApiKeyAuthentication(apiKeyDoc3, apiKeyCredentials3, clock, future3); final AuthenticationResult auth3 = future3.actionGet(); assertThat(auth3.getStatus(), is(AuthenticationResult.Status.SUCCESS)); assertThat(auth3.getValue(), notNullValue()); @@ -3210,8 +3211,6 @@ public void testValidateOwnerUserRoleDescriptorsWithWorkflowsRestriction() { public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Exception { final String apiKeyId = randomAlphaOfLength(12); - final Clock mockClock = mock(Clock.class); - when(mockClock.instant()).thenReturn(Instant.now()); // Scenario 1: Update with a new value { @@ -3230,7 +3229,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, notNullValue()); final Map updatedDoc = extractDocumentContent(builder); @@ -3253,7 +3252,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, nullValue()); } @@ -3274,7 +3273,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, notNullValue()); final Map updatedDoc = extractDocumentContent(builder); @@ -3297,7 +3296,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, notNullValue()); final Map updatedDoc = extractDocumentContent(builder); @@ -3319,12 +3318,82 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, nullValue()); } } + public void testCrossClusterApiKeyCertificateIdentityValidationSuccessful() throws Exception { + final String certificateIdentityPattern = "CN=(remote-cluster|test)-.*,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); + + final String matchingCertificateIdentity = "CN=remote-cluster-node1,OU=engineering,DC=example,DC=com"; + final ApiKeyCredentials credentials = new ApiKeyCredentials( + randomAlphaOfLength(12), + randomSecureStringOfLength(16), + ApiKey.Type.CROSS_CLUSTER, + matchingCertificateIdentity + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + ApiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); + + final AuthenticationResult result = future.get(); + assertThat(result, notNullValue()); + assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS)); + } + + public void testCrossClusterApiKeyCertificateIdentityValidationNoMatch() throws Exception { + final String certificateIdentityPattern = "CN=(remote-cluster|test)-.*,OU=engineering,DC=example,DC=com"; + + final String nonMatchingCertificateIdentity = "CN=unknown-host,OU=other,DC=different,DC=com"; + final ApiKeyCredentials credentials = new ApiKeyCredentials( + randomAlphaOfLength(12), + randomSecureStringOfLength(16), + ApiKey.Type.CROSS_CLUSTER, + nonMatchingCertificateIdentity + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); + + ApiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); + + final AuthenticationResult result = future.get(); + assertThat(result, notNullValue()); + assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.TERMINATE)); + assertThat( + result.getMessage(), + containsString( + "DN from provided certificate [" + + nonMatchingCertificateIdentity + + "] does not match API Key certificate identity pattern [" + + certificateIdentityPattern + + "]" + ) + ); + } + + public void testCrossClusterApiKeyCertificateIdentityValidationNoCertIdentity() throws Exception { + final String certificateIdentityPattern = "CN=(remote-cluster|test)-.*,OU=engineering,DC=example,DC=com"; + final ApiKeyCredentials credentialsWithoutCertIdentity = new ApiKeyCredentials( + randomAlphaOfLength(12), + randomSecureStringOfLength(16), + ApiKey.Type.CROSS_CLUSTER + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); + + ApiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentialsWithoutCertIdentity, clock, future); + + final AuthenticationResult result = future.get(); + assertThat(result, notNullValue()); + assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.TERMINATE)); + assertThat(result.getMessage(), equalTo("Expected signature for cross cluster API key, but no signature was provided")); + } + private static RoleDescriptor randomRoleDescriptorWithRemotePrivileges() { return new RoleDescriptor( randomAlphaOfLengthBetween(3, 90), @@ -3385,7 +3454,7 @@ public static Authentication createApiKeyAuthentication( ) ); PlainActionFuture> authenticationResultFuture = new PlainActionFuture<>(); - ApiKeyService.validateApiKeyTypeAndExpiration( + ApiKeyService.completeApiKeyAuthentication( apiKeyDoc, new ApiKeyService.ApiKeyCredentials("id", new SecureString(randomAlphaOfLength(16).toCharArray()), ApiKey.Type.REST), Clock.systemUTC(), @@ -3434,37 +3503,6 @@ public static Authentication createApiKeyAuthentication(ApiKeyService apiKeyServ } } - private ApiKeyService createApiKeyService(Settings baseSettings, FeatureService customFeatureService) { - final Settings settings = Settings.builder() - .put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true) - .put(baseSettings) - .build(); - final ClusterSettings clusterSettings = new ClusterSettings( - settings, - Sets.union( - ClusterSettings.BUILT_IN_CLUSTER_SETTINGS, - Set.of(ApiKeyService.DELETE_RETENTION_PERIOD, ApiKeyService.DELETE_INTERVAL) - ) - ); - final ApiKeyService service = new ApiKeyService( - settings, - clock, - client, - securityIndex, - ClusterServiceUtils.createClusterService(threadPool, clusterSettings), - cacheInvalidatorRegistry, - threadPool, - MeterRegistry.NOOP, - customFeatureService // Use the provided FeatureService - ); - if ("0s".equals(settings.get(ApiKeyService.CACHE_TTL_SETTING.getKey()))) { - verify(cacheInvalidatorRegistry, never()).registerCacheInvalidator(eq("api_key"), any()); - } else { - verify(cacheInvalidatorRegistry).registerCacheInvalidator(eq("api_key"), any()); - } - return service; - } - private ApiKeyService createApiKeyService() { final Settings settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build(); return createApiKeyService(settings); @@ -3615,7 +3653,7 @@ private ApiKey.Type parseTypeFromSourceMap(Map sourceMap) { } } - private ApiKeyDoc createCrossClusterApiKeyDocWithCertificateIdentity(String certificateIdentity) throws IOException { + private ApiKeyDoc createCrossClusterApiKeyDocWithCertificateIdentity(String certificateIdentity) { final String apiKey = randomAlphaOfLength(16); final char[] hash = getFastStoredHashAlgoForTests().hash(new SecureString(apiKey.toCharArray())); @@ -3690,4 +3728,5 @@ private static Authenticator.Context getAuthenticatorContext(ThreadContext threa private static ApiKey.Version randomApiKeyVersion() { return new ApiKey.Version(randomIntBetween(1, ApiKey.CURRENT_API_KEY_VERSION.version())); } + } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java index c93641fdfddba..7d7af40726a4d 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java @@ -12,17 +12,21 @@ import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.bytes.BytesArray; import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.ssl.PemUtils; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.SecurityIntegTestCase; import org.elasticsearch.test.SecuritySettingsSource; import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.action.apikey.ApiKey; +import org.elasticsearch.xpack.core.security.action.apikey.CertificateIdentity; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.CrossClusterApiKeyRoleDescriptorBuilder; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; @@ -31,11 +35,16 @@ import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; import org.elasticsearch.xpack.core.security.user.InternalUsers; +import org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignatureManager; +import org.elasticsearch.xpack.security.transport.X509CertificateSignature; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; @@ -45,9 +54,13 @@ import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy.WAIT_UNTIL; import static org.elasticsearch.xpack.core.security.authc.CrossClusterAccessSubjectInfo.CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY; import static org.elasticsearch.xpack.security.authc.CrossClusterAccessHeaders.CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY; +import static org.elasticsearch.xpack.security.transport.X509CertificateSignature.CROSS_CLUSTER_ACCESS_SIGNATURE_HEADER_KEY; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class CrossClusterAccessAuthenticationServiceIntegTests extends SecurityIntegTestCase { @@ -137,10 +150,21 @@ public void testInvalidHeaders() throws IOException { ) ); } + + try (var ignored = threadContext.stashContext()) { + new CrossClusterAccessHeaders( + getEncodedCrossClusterAccessApiKey(), + AuthenticationTestHelper.randomCrossClusterAccessSubjectInfo() + ).writeToContext(threadContext, createMockSignerWithNoCerts()); + authenticateAndAssertExpectedErrorMessage( + service, + msg -> assertThat(msg, equalTo("Provided signature does not contain any certificates")) + ); + } } - public void testAuthenticateHeadersSuccess() throws IOException { - final String encodedCrossClusterAccessApiKey = getEncodedCrossClusterAccessApiKey(); + public void testAuthenticateHeadersSuccess() throws IOException, CertificateException { + final String encodedCrossClusterAccessApiKey = getEncodedCrossClusterAccessApiKeyWithCertIdentity(); final String nodeName = internalCluster().getRandomNodeName(); final ThreadContext threadContext = internalCluster().getInstance(SecurityContext.class, nodeName).getThreadContext(); final CrossClusterAccessAuthenticationService service = getCrossClusterAccessAuthenticationService(nodeName); @@ -149,7 +173,12 @@ public void testAuthenticateHeadersSuccess() throws IOException { addRandomizedHeaders(threadContext, encodedCrossClusterAccessApiKey); final PlainActionFuture future = new PlainActionFuture<>(); Map headers = withRandomizedAdditionalSecurityHeaders( - Map.of(CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY, encodedCrossClusterAccessApiKey) + Map.of( + CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY, + encodedCrossClusterAccessApiKey, + CROSS_CLUSTER_ACCESS_SIGNATURE_HEADER_KEY, + createTestSignature().encodeToString() + ) ); final ApiKeyService.ApiKeyCredentials credentials = service.extractApiKeyCredentialsFromHeaders(headers); service.tryAuthenticate(credentials, future); @@ -198,10 +227,29 @@ public void testGetApiKeyCredentialsFromHeaders() { ); } + { + ElasticsearchSecurityException ex = expectThrows( + ElasticsearchSecurityException.class, + () -> service.extractApiKeyCredentialsFromHeaders( + withRandomizedAdditionalSecurityHeaders( + Map.of( + CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY, + getEncodedCrossClusterAccessApiKey(), + CROSS_CLUSTER_ACCESS_SIGNATURE_HEADER_KEY, + "not a valid signature" + ) + ) + ) + ); + assertThat(ex.getCause(), instanceOf(IllegalArgumentException.class)); + assertThat(ex.getCause().getMessage(), containsString("Illegal base64 character 20")); + } } - public void testAuthenticateHeadersFailure() throws IOException { - final EncodedKeyWithId encodedCrossClusterAccessApiKeyWithId = getEncodedCrossClusterAccessApiKeyWithId(); + public void testAuthenticateHeadersFailure() throws IOException, CertificateException { + final EncodedKeyWithId encodedCrossClusterAccessApiKeyWithId = getEncodedCrossClusterAccessApiKeyWithId( + new CertificateIdentity("CN=ins*") + ); final EncodedKeyWithId encodedRestApiKeyWithId = getEncodedRestApiKeyWithId(); final String nodeName = internalCluster().getRandomNodeName(); final ThreadContext threadContext = internalCluster().getInstance(SecurityContext.class, nodeName).getThreadContext(); @@ -260,6 +308,22 @@ public void testAuthenticateHeadersFailure() throws IOException { assertThat(actualException.getCause(), instanceOf(ElasticsearchSecurityException.class)); assertThat(actualException.getCause().getMessage(), containsString("unable to find apikey with id")); } + + try (var ignored = threadContext.stashContext()) { + addRandomizedHeaders(threadContext, encodedCrossClusterAccessApiKeyWithId.encoded); + final Map headers = withRandomizedAdditionalSecurityHeaders( + Map.of(CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY, encodedCrossClusterAccessApiKeyWithId.encoded) + ); + final ApiKeyService.ApiKeyCredentials credentials = service.extractApiKeyCredentialsFromHeaders(headers); + final PlainActionFuture future = new PlainActionFuture<>(); + service.tryAuthenticate(credentials, future); + final ExecutionException actualException = expectThrows(ExecutionException.class, future::get); + assertThat(actualException.getCause(), instanceOf(ElasticsearchSecurityException.class)); + assertThat( + actualException.getCause().getMessage(), + containsString("Expected signature for cross cluster API key, but no signature was provided") + ); + } } private Map withRandomizedAdditionalSecurityHeaders(Map headers) throws IOException { @@ -283,7 +347,7 @@ private Map withRandomizedAdditionalSecurityHeaders(Map (X509Certificate) cert) + .toArray(X509Certificate[]::new); + } + + private X509CertificateSignature createTestSignature() throws CertificateException, IOException { + return new X509CertificateSignature(getTestCertificates(), "SHA256withRSA", new BytesArray(new byte[] { 1, 2, 3, 4 })); + } + + private CrossClusterApiKeySignatureManager.Signer createMockSigner() throws CertificateException, IOException { + var signer = mock(CrossClusterApiKeySignatureManager.Signer.class); + when(signer.sign(anyString(), anyString())).thenReturn(createTestSignature()); + return signer; + } + + private CrossClusterApiKeySignatureManager.Signer createMockSignerWithNoCerts() { + var signer = mock(CrossClusterApiKeySignatureManager.Signer.class); + when(signer.sign(anyString(), anyString())).thenReturn( + new X509CertificateSignature(new X509Certificate[0], "SHA256withRSA", new BytesArray(new byte[] { 1, 2, 3, 4 })) + ); + return signer; + } + } From 0cc2ea37afd686ccc66bc14230de9881bd7065a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Fred=C3=A9n?= <109296772+jfreden@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:04:33 +0200 Subject: [PATCH 02/14] Update docs/changelog/136299.yaml --- docs/changelog/136299.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/136299.yaml diff --git a/docs/changelog/136299.yaml b/docs/changelog/136299.yaml new file mode 100644 index 0000000000000..852c59db3bc97 --- /dev/null +++ b/docs/changelog/136299.yaml @@ -0,0 +1,5 @@ +pr: 136299 +summary: Validate certificate identity from cross cluster creds +area: Security +type: enhancement +issues: [] From 69c9a172156e27cd0cf5145a72f639c71b919173 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Wed, 15 Oct 2025 09:11:29 +0000 Subject: [PATCH 03/14] [CI] Auto commit changes from spotless --- ...RemoteClusterSecurityCrossClusterApiKeySigningIT.java | 1 + .../xpack/security/authc/CrossClusterAccessHeaders.java | 3 ++- .../transport/CrossClusterApiKeySignatureManager.java | 9 +++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java index e2ad0f67a3bbf..fba76ee9ca494 100644 --- a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.remotecluster; import io.netty.handler.codec.http.HttpMethod; + import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.client.Request; import org.elasticsearch.client.RequestOptions; diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessHeaders.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessHeaders.java index 776831496cf34..1439d95f96b41 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessHeaders.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessHeaders.java @@ -14,11 +14,12 @@ import org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignatureManager; import org.elasticsearch.xpack.security.transport.X509CertificateSignature; -import javax.security.auth.x500.X500Principal; import java.io.IOException; import java.util.Arrays; import java.util.Objects; +import javax.security.auth.x500.X500Principal; + import static org.elasticsearch.xpack.core.security.authc.CrossClusterAccessSubjectInfo.CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY; public final class CrossClusterAccessHeaders { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignatureManager.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignatureManager.java index 7957919ba03ff..b0b91cf7b30f2 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignatureManager.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignatureManager.java @@ -20,10 +20,6 @@ import org.elasticsearch.logging.Logger; import org.elasticsearch.xpack.core.ssl.SslSettingsLoader; -import javax.net.ssl.X509ExtendedTrustManager; -import javax.net.ssl.X509KeyManager; -import javax.net.ssl.X509TrustManager; -import javax.security.auth.x500.X500Principal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -43,6 +39,11 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import javax.net.ssl.X509ExtendedTrustManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; + import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySigningSettings.KEYSTORE_ALIAS_SUFFIX; import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySigningSettings.SETTINGS_PART_DIAGNOSE_TRUST; import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySigningSettings.SETTINGS_PART_SIGNING; From eb7a3f7988067b04a5c30adcb6008f58b4da66d9 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Wed, 15 Oct 2025 11:58:43 +0200 Subject: [PATCH 04/14] fixup! Pattern match --- .../org/elasticsearch/xpack/security/authc/ApiKeyService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 234ab14470ff2..6551dd9f2a57c 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -1540,7 +1540,7 @@ static void completeApiKeyAuthentication( private static boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) { logger.trace("Validating certificate identity [{}] against [{}]", certificateIdentity, certificateIdentityPattern); // Consider adding a cache if this causes performance problems - return Pattern.compile(certificateIdentityPattern).matcher(certificateIdentity).matches(); + return Pattern.matches(certificateIdentityPattern, certificateIdentity); } ApiKeyCredentials parseCredentialsFromApiKeyString(SecureString apiKeyString) { From d63b4ec67f5ce78f37d22388b38c2122cb849e55 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Wed, 15 Oct 2025 16:38:34 +0200 Subject: [PATCH 05/14] Add pattern cache --- .../xpack/security/authc/ApiKeyService.java | 27 ++++++++++++++++--- .../security/authc/ApiKeyServiceTests.java | 15 ++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 6551dd9f2a57c..ceb1981a6e0f3 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -208,6 +208,7 @@ public class ApiKeyService implements Closeable { TimeValue.timeValueMinutes(15), Property.NodeScope ); + private static final int MAX_PATTERN_CACHE_SIZE = 100; private static final RoleDescriptor.Parser ROLE_DESCRIPTOR_PARSER = RoleDescriptor.parserBuilder().allowRestriction(true).build(); @@ -219,6 +220,7 @@ public class ApiKeyService implements Closeable { private final boolean enabled; private final Settings settings; private final InactiveApiKeysRemover inactiveApiKeysRemover; + private final Cache certificateIdentityPatternCache; private final Cache> apiKeyAuthCache; private final Hasher cacheHasher; private final ThreadPool threadPool; @@ -271,6 +273,11 @@ public ApiKeyService( .build(); final TimeValue doc_ttl = DOC_CACHE_TTL_SETTING.get(settings); this.apiKeyDocCache = doc_ttl.getNanos() == 0 ? null : new ApiKeyDocCache(doc_ttl, maximumWeight); + this.certificateIdentityPatternCache = CacheBuilder.builder() + .setExpireAfterAccess(ttl) + .setMaximumWeight(MAX_PATTERN_CACHE_SIZE) + .build(); + cacheInvalidatorRegistry.registerCacheInvalidator("api_key", new CacheInvalidatorRegistry.CacheInvalidator() { @Override public void invalidate(Collection keys) { @@ -310,6 +317,7 @@ public void invalidateAll() { } else { this.apiKeyAuthCache = null; this.apiKeyDocCache = null; + this.certificateIdentityPatternCache = null; } if (enabled) { @@ -1472,7 +1480,7 @@ Cache getRoleDescriptorsBytesCache() { } // package-private for testing - static void completeApiKeyAuthentication( + void completeApiKeyAuthentication( ApiKeyDoc apiKeyDoc, ApiKeyCredentials credentials, Clock clock, @@ -1537,10 +1545,21 @@ static void completeApiKeyAuthentication( } } - private static boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) { + private boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) { logger.trace("Validating certificate identity [{}] against [{}]", certificateIdentity, certificateIdentityPattern); - // Consider adding a cache if this causes performance problems - return Pattern.matches(certificateIdentityPattern, certificateIdentity); + + try { + Pattern pattern = certificateIdentityPatternCache.computeIfAbsent(certificateIdentityPattern, Pattern::compile); + return pattern.matcher(certificateIdentity).matches(); + } catch (ExecutionException e) { + logger.error( + "Failed to validate certificate identity [{}] against pattern [{}] using cache. Falling back to regular matching", + certificateIdentity, + certificateIdentityPattern, + e + ); + return Pattern.matches(certificateIdentityPattern, certificateIdentity); + } } ApiKeyCredentials parseCredentialsFromApiKeyString(SecureString apiKeyString) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index 4f17de94c56fb..b927322d7f9b1 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -3081,6 +3081,7 @@ public void testAuthenticationFailureWithApiKeyTypeMismatch() throws Exception { } public void testCompleteApiKeyAuthentication() throws IOException { + var apiKeyService = createApiKeyService(); final var apiKeyId = randomAlphaOfLength(12); final var apiKey = randomAlphaOfLength(16); final var hasher = getFastStoredHashAlgoForTests(); @@ -3101,7 +3102,7 @@ public void testCompleteApiKeyAuthentication() throws IOException { final ApiKey.Type expectedType1 = randomValueOtherThan(apiKeyDoc1.type, () -> randomFrom(ApiKey.Type.values())); final ApiKeyCredentials apiKeyCredentials1 = getApiKeyCredentials(apiKeyId, apiKey, expectedType1); final PlainActionFuture> future1 = new PlainActionFuture<>(); - ApiKeyService.completeApiKeyAuthentication(apiKeyDoc1, apiKeyCredentials1, clock, future1); + apiKeyService.completeApiKeyAuthentication(apiKeyDoc1, apiKeyCredentials1, clock, future1); final AuthenticationResult auth1 = future1.actionGet(); assertThat(auth1.getStatus(), is(AuthenticationResult.Status.TERMINATE)); assertThat(auth1.getValue(), nullValue()); @@ -3122,7 +3123,7 @@ public void testCompleteApiKeyAuthentication() throws IOException { final var apiKeyDoc2 = buildApiKeyDoc(hash, pastTime, false, -1, randomAlphaOfLengthBetween(3, 8), Version.CURRENT.id); final ApiKeyCredentials apiKeyCredentials2 = getApiKeyCredentials(apiKeyId, apiKey, apiKeyDoc2.type); final PlainActionFuture> future2 = new PlainActionFuture<>(); - ApiKeyService.completeApiKeyAuthentication(apiKeyDoc2, apiKeyCredentials2, clock, future2); + apiKeyService.completeApiKeyAuthentication(apiKeyDoc2, apiKeyCredentials2, clock, future2); final AuthenticationResult auth2 = future2.actionGet(); assertThat(auth2.getStatus(), is(AuthenticationResult.Status.CONTINUE)); assertThat(auth2.getValue(), nullValue()); @@ -3139,7 +3140,7 @@ public void testCompleteApiKeyAuthentication() throws IOException { ); final ApiKeyCredentials apiKeyCredentials3 = getApiKeyCredentials(apiKeyId, apiKey, apiKeyDoc3.type); final PlainActionFuture> future3 = new PlainActionFuture<>(); - ApiKeyService.completeApiKeyAuthentication(apiKeyDoc3, apiKeyCredentials3, clock, future3); + apiKeyService.completeApiKeyAuthentication(apiKeyDoc3, apiKeyCredentials3, clock, future3); final AuthenticationResult auth3 = future3.actionGet(); assertThat(auth3.getStatus(), is(AuthenticationResult.Status.SUCCESS)); assertThat(auth3.getValue(), notNullValue()); @@ -3337,7 +3338,7 @@ public void testCrossClusterApiKeyCertificateIdentityValidationSuccessful() thro ); final PlainActionFuture> future = new PlainActionFuture<>(); - ApiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); + createApiKeyService().completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); final AuthenticationResult result = future.get(); assertThat(result, notNullValue()); @@ -3358,7 +3359,7 @@ public void testCrossClusterApiKeyCertificateIdentityValidationNoMatch() throws final PlainActionFuture> future = new PlainActionFuture<>(); final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); - ApiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); + createApiKeyService().completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); final AuthenticationResult result = future.get(); assertThat(result, notNullValue()); @@ -3386,7 +3387,7 @@ public void testCrossClusterApiKeyCertificateIdentityValidationNoCertIdentity() final PlainActionFuture> future = new PlainActionFuture<>(); final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); - ApiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentialsWithoutCertIdentity, clock, future); + createApiKeyService().completeApiKeyAuthentication(apiKeyDoc, credentialsWithoutCertIdentity, clock, future); final AuthenticationResult result = future.get(); assertThat(result, notNullValue()); @@ -3454,7 +3455,7 @@ public static Authentication createApiKeyAuthentication( ) ); PlainActionFuture> authenticationResultFuture = new PlainActionFuture<>(); - ApiKeyService.completeApiKeyAuthentication( + apiKeyService.completeApiKeyAuthentication( apiKeyDoc, new ApiKeyService.ApiKeyCredentials("id", new SecureString(randomAlphaOfLength(16).toCharArray()), ApiKey.Type.REST), Clock.systemUTC(), From e598e69be3970e3ae51f3c35db7f6311f951955a Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Thu, 16 Oct 2025 11:14:11 +0200 Subject: [PATCH 06/14] Add tests for pattern cache --- .../xpack/security/Security.java | 1 + .../xpack/security/authc/ApiKeyService.java | 46 +++++++----- .../security/authc/ApiKeyServiceTests.java | 72 +++++++++++++++++++ 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 90e8d34b68f7a..ea742a74fc033 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1643,6 +1643,7 @@ public static List> getSettings( settingsList.add(ApiKeyService.CACHE_MAX_KEYS_SETTING); settingsList.add(ApiKeyService.CACHE_TTL_SETTING); settingsList.add(ApiKeyService.DOC_CACHE_TTL_SETTING); + settingsList.add(ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING); settingsList.add(NativePrivilegeStore.CACHE_MAX_APPLICATIONS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_TTL_SETTING); settingsList.add(OPERATOR_PRIVILEGES_ENABLED); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index ceb1981a6e0f3..009f128e4da40 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -208,7 +208,12 @@ public class ApiKeyService implements Closeable { TimeValue.timeValueMinutes(15), Property.NodeScope ); - private static final int MAX_PATTERN_CACHE_SIZE = 100; + public static final Setting CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING = Setting.timeSetting( + "xpack.security.authc.api_key.certificate_identity_pattern_cache.ttl", + TimeValue.timeValueHours(48L), + Property.NodeScope + ); + private static final int MAX_PATTERN_CACHE_SIZE = 1000; private static final RoleDescriptor.Parser ROLE_DESCRIPTOR_PARSER = RoleDescriptor.parserBuilder().allowRestriction(true).build(); @@ -271,12 +276,13 @@ public ApiKeyService( .setMaximumWeight(maximumWeight) .removalListener(getAuthCacheRemovalListener(maximumWeight)) .build(); - final TimeValue doc_ttl = DOC_CACHE_TTL_SETTING.get(settings); - this.apiKeyDocCache = doc_ttl.getNanos() == 0 ? null : new ApiKeyDocCache(doc_ttl, maximumWeight); - this.certificateIdentityPatternCache = CacheBuilder.builder() - .setExpireAfterAccess(ttl) - .setMaximumWeight(MAX_PATTERN_CACHE_SIZE) - .build(); + final TimeValue docTtl = DOC_CACHE_TTL_SETTING.get(settings); + this.apiKeyDocCache = docTtl.getNanos() == 0 ? null : new ApiKeyDocCache(docTtl, maximumWeight); + + final TimeValue patternTtl = CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING.get(settings); + this.certificateIdentityPatternCache = patternTtl.getNanos() == 0 + ? null + : CacheBuilder.builder().setExpireAfterAccess(patternTtl).setMaximumWeight(MAX_PATTERN_CACHE_SIZE).build(); cacheInvalidatorRegistry.registerCacheInvalidator("api_key", new CacheInvalidatorRegistry.CacheInvalidator() { @Override @@ -1547,19 +1553,23 @@ void completeApiKeyAuthentication( private boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) { logger.trace("Validating certificate identity [{}] against [{}]", certificateIdentity, certificateIdentityPattern); + return getCertificateIdentityPattern(certificateIdentityPattern).matcher(certificateIdentity).matches(); + } - try { - Pattern pattern = certificateIdentityPatternCache.computeIfAbsent(certificateIdentityPattern, Pattern::compile); - return pattern.matcher(certificateIdentity).matches(); - } catch (ExecutionException e) { - logger.error( - "Failed to validate certificate identity [{}] against pattern [{}] using cache. Falling back to regular matching", - certificateIdentity, - certificateIdentityPattern, - e - ); - return Pattern.matches(certificateIdentityPattern, certificateIdentity); + // Visible for testing + Pattern getCertificateIdentityPattern(String certificateIdentityPattern) { + if (certificateIdentityPatternCache != null) { + try { + return certificateIdentityPatternCache.computeIfAbsent(certificateIdentityPattern, Pattern::compile); + } catch (ExecutionException e) { + logger.error( + "Failed to validate certificate identity against pattern [{}] using cache. Falling back to regular matching", + certificateIdentityPattern, + e + ); + } } + return Pattern.compile(certificateIdentityPattern); } ApiKeyCredentials parseCredentialsFromApiKeyString(SecureString apiKeyString) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index b927322d7f9b1..39a3053399ab0 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -157,6 +157,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.LongStream; @@ -3395,6 +3396,77 @@ public void testCrossClusterApiKeyCertificateIdentityValidationNoCertIdentity() assertThat(result.getMessage(), equalTo("Expected signature for cross cluster API key, but no signature was provided")); } + public void testPatternCache() throws ExecutionException, InterruptedException { + final String certificateIdentityPattern = "CN=(remote-cluster|test)-.*,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); + + final String matchingCertificateIdentity = "CN=remote-cluster-node1,OU=engineering,DC=example,DC=com"; + final ApiKeyCredentials credentials = new ApiKeyCredentials( + randomAlphaOfLength(12), + randomSecureStringOfLength(16), + ApiKey.Type.CROSS_CLUSTER, + matchingCertificateIdentity + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + var apiKeyService = spy(createApiKeyService()); + + final var currentPatternObject = new AtomicReference(); + doAnswer(invocationOnMock -> { + Pattern newPattern = (Pattern) invocationOnMock.callRealMethod(); + if (currentPatternObject.get() != null) { + assertSame(currentPatternObject.get(), newPattern); + } + currentPatternObject.set(newPattern); + return newPattern; + }).when(apiKeyService).getCertificateIdentityPattern(certificateIdentityPattern); + + for (int i = 0; i < randomIntBetween(3, 10); i++) { + apiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); + final AuthenticationResult result = future.get(); + assertThat(result, notNullValue()); + assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS)); + } + } + + public void testPatternCacheDisabled() throws ExecutionException, InterruptedException { + final Settings settings = Settings.builder() + .put( + randomFrom(ApiKeyService.CACHE_TTL_SETTING.getKey(), ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING.getKey()), + "0s" + ) + .build(); + + final String certificateIdentityPattern = "CN=(remote-cluster|test)-.*,OU=engineering,DC=example,DC=com"; + final ApiKeyDoc apiKeyDoc = createCrossClusterApiKeyDocWithCertificateIdentity(certificateIdentityPattern); + + final String matchingCertificateIdentity = "CN=remote-cluster-node1,OU=engineering,DC=example,DC=com"; + final ApiKeyCredentials credentials = new ApiKeyCredentials( + randomAlphaOfLength(12), + randomSecureStringOfLength(16), + ApiKey.Type.CROSS_CLUSTER, + matchingCertificateIdentity + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + var apiKeyService = spy(createApiKeyService(settings)); + + final var currentPatternObject = new AtomicReference(); + doAnswer(invocationOnMock -> { + Pattern newPattern = (Pattern) invocationOnMock.callRealMethod(); + assertNotEquals(currentPatternObject.get(), newPattern); + currentPatternObject.set(newPattern); + return newPattern; + }).when(apiKeyService).getCertificateIdentityPattern(certificateIdentityPattern); + + for (int i = 0; i < randomIntBetween(3, 10); i++) { + apiKeyService.completeApiKeyAuthentication(apiKeyDoc, credentials, clock, future); + final AuthenticationResult result = future.get(); + assertThat(result, notNullValue()); + assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS)); + } + } + private static RoleDescriptor randomRoleDescriptorWithRemotePrivileges() { return new RoleDescriptor( randomAlphaOfLengthBetween(3, 90), From b4dfd67b28b588757198a3d31291df1cdc4c6270 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Thu, 16 Oct 2025 11:25:21 +0200 Subject: [PATCH 07/14] fixup! logging --- .../elasticsearch/xpack/security/authc/ApiKeyService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 009f128e4da40..a6fe7e1a7d743 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -1563,8 +1563,10 @@ Pattern getCertificateIdentityPattern(String certificateIdentityPattern) { return certificateIdentityPatternCache.computeIfAbsent(certificateIdentityPattern, Pattern::compile); } catch (ExecutionException e) { logger.error( - "Failed to validate certificate identity against pattern [{}] using cache. Falling back to regular matching", - certificateIdentityPattern, + Strings.format( + "Failed to validate certificate identity against pattern [%s] using cache. Falling back to regular matching", + certificateIdentityPattern + ), e ); } From 59dbd74c141d1009427b54117400f0e46dcb5380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Fred=C3=A9n?= <109296772+jfreden@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:13:13 +0200 Subject: [PATCH 08/14] Update x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java Co-authored-by: Tim Vernum --- .../elasticsearch/xpack/security/authc/ApiKeyService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index a6fe7e1a7d743..3dbd200fbdbb4 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -1780,6 +1780,11 @@ public ApiKey.Type getExpectedType() { return expectedType; } + /** + * The identity (Subject DistinguishedName) of the X.509 certificate that was provided by the client + * alongside the API during authenticate. + * At the time of writing, the only place where this is used is for cross cluster request signing + */ public String getCertificateIdentity() { return certificateIdentity; } From 649a7e481ee810ad0079aafebc2eca9bb9fec102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Fred=C3=A9n?= <109296772+jfreden@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:14:05 +0200 Subject: [PATCH 09/14] Update x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java Co-authored-by: Tim Vernum --- .../org/elasticsearch/xpack/security/authc/ApiKeyService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 3dbd200fbdbb4..5cf52e39f097b 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -1509,7 +1509,8 @@ void completeApiKeyAuthentication( if (apiKeyDoc.certificateIdentity != null) { if (credentials.getCertificateIdentity() == null) { listener.onResponse( - AuthenticationResult.terminate("Expected signature for cross cluster API key, but no signature was provided") + AuthenticationResult.terminate( + Strings.format("API key (type:[%s], id:[%s]) requires certificate identity [%s], but no certificate was provided", apiKeyDoc.type.value(), credentials.getId(), apiKeyDoc.certificateIdentity)); ); return; } From 4d075354b2c56332e18dbd37379ceb1600bf7351 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Fri, 17 Oct 2025 12:00:21 +0200 Subject: [PATCH 10/14] fixup! Code review comments --- ...sterSecurityCrossClusterApiKeySigningIT.java | 7 ++++++- .../elasticsearch/xpack/security/Security.java | 2 ++ .../xpack/security/authc/ApiKeyService.java | 17 ++++++++++++++--- ...CrossClusterAccessAuthenticationService.java | 8 +++++--- ...erAccessAuthenticationServiceIntegTests.java | 6 +++++- 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java index fba76ee9ca494..810f88ce766a8 100644 --- a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityCrossClusterApiKeySigningIT.java @@ -151,7 +151,12 @@ public void testCrossClusterSearchWithCrossClusterApiKeySigning() throws Excepti .putNull("cluster.remote.my_remote_cluster.signing.key") .build() ); - assertCrossClusterAuthFail("Expected signature for cross cluster API key, but no signature was provided"); + + assertCrossClusterAuthFail( + "API key (type:[cross_cluster], id:[" + + MY_REMOTE_API_KEY_MAP_REF.get().get("id") + + "]) requires certificate identity matching [" + ); // Reset updateClusterSettings( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index ea742a74fc033..7608e72e0c2f0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -472,6 +472,7 @@ import static org.elasticsearch.xpack.core.security.SecurityField.FIELD_LEVEL_SECURITY_FEATURE; import static org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField.INDICES_PERMISSIONS_VALUE; import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.INCLUDED_RESERVED_ROLES_SETTING; +import static org.elasticsearch.xpack.security.authc.ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING; import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED; import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer.QUERYABLE_BUILT_IN_ROLES_ENABLED; import static org.elasticsearch.xpack.security.transport.SSLEngineUtils.extractClientCertificates; @@ -1644,6 +1645,7 @@ public static List> getSettings( settingsList.add(ApiKeyService.CACHE_TTL_SETTING); settingsList.add(ApiKeyService.DOC_CACHE_TTL_SETTING); settingsList.add(ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING); + settingsList.add(CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_MAX_APPLICATIONS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_TTL_SETTING); settingsList.add(OPERATOR_PRIVILEGES_ENABLED); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 5cf52e39f097b..7c0746ce7e871 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -213,7 +213,11 @@ public class ApiKeyService implements Closeable { TimeValue.timeValueHours(48L), Property.NodeScope ); - private static final int MAX_PATTERN_CACHE_SIZE = 1000; + public static final Setting CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING = Setting.intSetting( + "xpack.security.authc.api_key.certificate_identity_pattern_cache.max_keys", + 100, + Property.NodeScope + ); private static final RoleDescriptor.Parser ROLE_DESCRIPTOR_PARSER = RoleDescriptor.parserBuilder().allowRestriction(true).build(); @@ -280,9 +284,10 @@ public ApiKeyService( this.apiKeyDocCache = docTtl.getNanos() == 0 ? null : new ApiKeyDocCache(docTtl, maximumWeight); final TimeValue patternTtl = CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING.get(settings); + final int maximumPatternWeight = CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING.get(settings); this.certificateIdentityPatternCache = patternTtl.getNanos() == 0 ? null - : CacheBuilder.builder().setExpireAfterAccess(patternTtl).setMaximumWeight(MAX_PATTERN_CACHE_SIZE).build(); + : CacheBuilder.builder().setExpireAfterAccess(patternTtl).setMaximumWeight(maximumPatternWeight).build(); cacheInvalidatorRegistry.registerCacheInvalidator("api_key", new CacheInvalidatorRegistry.CacheInvalidator() { @Override @@ -1510,7 +1515,13 @@ void completeApiKeyAuthentication( if (credentials.getCertificateIdentity() == null) { listener.onResponse( AuthenticationResult.terminate( - Strings.format("API key (type:[%s], id:[%s]) requires certificate identity [%s], but no certificate was provided", apiKeyDoc.type.value(), credentials.getId(), apiKeyDoc.certificateIdentity)); + Strings.format( + "API key (type:[%s], id:[%s]) requires certificate identity matching [%s], but no certificate was provided", + apiKeyDoc.type.value(), + credentials.getId(), + apiKeyDoc.certificateIdentity + ) + ) ); return; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java index 63075f1337dba..62e59cbbcadbd 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationService.java @@ -69,14 +69,16 @@ public void authenticate(final String action, final TransportRequest request, fi try { // parse and add as authentication token as early as possible so that failure events in audit log include API key ID crossClusterAccessHeaders = CrossClusterAccessHeaders.readFromContext(threadContext); + // Extract credentials, including certificate identity from the optional signature without actually verifying the signature final ApiKeyService.ApiKeyCredentials apiKeyCredentials = crossClusterAccessHeaders.credentials(); assert ApiKey.Type.CROSS_CLUSTER == apiKeyCredentials.getExpectedType(); // authn must verify only the provided api key and not try to extract any other credential from the thread context authcContext = authenticationService.newContext(action, request, apiKeyCredentials); - var signature = crossClusterAccessHeaders.signature(); + var signingInfo = crossClusterAccessHeaders.signature(); - // Always validate a signature if provided - if (signature != null && verifySignature(authcContext, signature, crossClusterAccessHeaders, listener) == false) { + // Verify the signing info if provided. The signing info contains both the signature and the certificate identity, but only the + // signature is validated here. The certificate identity is validated later as part of the ApiKeyCredentials validation + if (signingInfo != null && verifySignature(authcContext, signingInfo, crossClusterAccessHeaders, listener) == false) { return; } } catch (Exception ex) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java index 7d7af40726a4d..44030dd07c895 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/CrossClusterAccessAuthenticationServiceIntegTests.java @@ -321,7 +321,11 @@ public void testAuthenticateHeadersFailure() throws IOException, CertificateExce assertThat(actualException.getCause(), instanceOf(ElasticsearchSecurityException.class)); assertThat( actualException.getCause().getMessage(), - containsString("Expected signature for cross cluster API key, but no signature was provided") + containsString( + "API key (type:[cross_cluster], id:[" + + encodedCrossClusterAccessApiKeyWithId.id + + "]) requires certificate identity matching [CN=ins*], but no certificate was provided" + ) ); } } From 2203889b161d3a529f124b762aa0e95a69b6c540 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 17 Oct 2025 10:08:25 +0000 Subject: [PATCH 11/14] [CI] Auto commit changes from spotless --- .../elasticsearch/xpack/security/authc/ApiKeyService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index 7c0746ce7e871..7746db56651b4 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -1793,9 +1793,9 @@ public ApiKey.Type getExpectedType() { } /** - * The identity (Subject DistinguishedName) of the X.509 certificate that was provided by the client - * alongside the API during authenticate. - * At the time of writing, the only place where this is used is for cross cluster request signing + * The identity (Subject DistinguishedName) of the X.509 certificate that was provided by the client + * alongside the API during authenticate. + * At the time of writing, the only place where this is used is for cross cluster request signing */ public String getCertificateIdentity() { return certificateIdentity; From ed4093a08ff3cfe85b2b9f394c93300dc609db14 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Fri, 17 Oct 2025 13:26:21 +0200 Subject: [PATCH 12/14] fixup! CI --- .../xpack/security/authc/ApiKeyServiceTests.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index 39a3053399ab0..a453b21b402ea 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -3393,7 +3393,16 @@ public void testCrossClusterApiKeyCertificateIdentityValidationNoCertIdentity() final AuthenticationResult result = future.get(); assertThat(result, notNullValue()); assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.TERMINATE)); - assertThat(result.getMessage(), equalTo("Expected signature for cross cluster API key, but no signature was provided")); + assertThat( + result.getMessage(), + equalTo( + "API key (type:[cross_cluster], id:[" + + credentialsWithoutCertIdentity.getId() + + "]) requires certificate identity matching [" + + certificateIdentityPattern + + "], but no certificate was provided" + ) + ); } public void testPatternCache() throws ExecutionException, InterruptedException { From 094c15f2511d61c3def183c70337c8402f7153b9 Mon Sep 17 00:00:00 2001 From: Johannes Freden Jansson Date: Fri, 17 Oct 2025 13:29:57 +0200 Subject: [PATCH 13/14] fixup! Import --- .../main/java/org/elasticsearch/xpack/security/Security.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 7608e72e0c2f0..b262a6ee65cee 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -9,7 +9,6 @@ import io.netty.channel.Channel; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpUtil; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.lucene.util.SetOnce; @@ -472,7 +471,6 @@ import static org.elasticsearch.xpack.core.security.SecurityField.FIELD_LEVEL_SECURITY_FEATURE; import static org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField.INDICES_PERMISSIONS_VALUE; import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.INCLUDED_RESERVED_ROLES_SETTING; -import static org.elasticsearch.xpack.security.authc.ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING; import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED; import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer.QUERYABLE_BUILT_IN_ROLES_ENABLED; import static org.elasticsearch.xpack.security.transport.SSLEngineUtils.extractClientCertificates; @@ -1645,7 +1643,7 @@ public static List> getSettings( settingsList.add(ApiKeyService.CACHE_TTL_SETTING); settingsList.add(ApiKeyService.DOC_CACHE_TTL_SETTING); settingsList.add(ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_TTL_SETTING); - settingsList.add(CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING); + settingsList.add(ApiKeyService.CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_MAX_APPLICATIONS_SETTING); settingsList.add(NativePrivilegeStore.CACHE_TTL_SETTING); settingsList.add(OPERATOR_PRIVILEGES_ENABLED); From a71ca11ed100b1f8512f573f176b6f8d0d59588b Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 17 Oct 2025 11:37:17 +0000 Subject: [PATCH 14/14] [CI] Auto commit changes from spotless --- .../src/main/java/org/elasticsearch/xpack/security/Security.java | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index b262a6ee65cee..8199d75a604d0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -9,6 +9,7 @@ import io.netty.channel.Channel; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpUtil; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.lucene.util.SetOnce;