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: [] 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..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 @@ -25,6 +25,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 +39,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 +50,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 +65,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 +94,107 @@ 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 [("); - // TODO add test for certificate identity not configured for API key but signature provided (should 200) + // 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(); + + // Reset skip_unavailable + updateClusterSettings( + Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(false)).build() + ); + + // 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() + ); - // TODO add test for certificate identity not configured for API key but wrong signature provided (should 401) + assertCrossClusterAuthFail( + "API key (type:[cross_cluster], id:[" + + MY_REMOTE_API_KEY_MAP_REF.get().get("id") + + "]) requires certificate identity matching [" + ); - // 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 +309,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/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 90e8d34b68f7a..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 @@ -1643,6 +1643,8 @@ 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(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); 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..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 @@ -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; @@ -207,6 +208,16 @@ public class ApiKeyService implements Closeable { TimeValue.timeValueMinutes(15), Property.NodeScope ); + 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 + ); + 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(); @@ -218,6 +229,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; @@ -268,8 +280,15 @@ 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); + 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); + final int maximumPatternWeight = CERTIFICATE_IDENTITY_PATTERN_CACHE_MAX_KEYS_SETTING.get(settings); + this.certificateIdentityPatternCache = patternTtl.getNanos() == 0 + ? null + : CacheBuilder.builder().setExpireAfterAccess(patternTtl).setMaximumWeight(maximumPatternWeight).build(); + cacheInvalidatorRegistry.registerCacheInvalidator("api_key", new CacheInvalidatorRegistry.CacheInvalidator() { @Override public void invalidate(Collection keys) { @@ -309,6 +328,7 @@ public void invalidateAll() { } else { this.apiKeyAuthCache = null; this.apiKeyDocCache = null; + this.certificateIdentityPatternCache = null; } if (enabled) { @@ -1396,7 +1416,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 +1436,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 +1459,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 +1491,7 @@ Cache getRoleDescriptorsBytesCache() { } // package-private for testing - static void validateApiKeyTypeAndExpiration( + void completeApiKeyAuthentication( ApiKeyDoc apiKeyDoc, ApiKeyCredentials credentials, Clock clock, @@ -1491,6 +1511,34 @@ static void validateApiKeyTypeAndExpiration( return; } + if (apiKeyDoc.certificateIdentity != null) { + if (credentials.getCertificateIdentity() == null) { + listener.onResponse( + AuthenticationResult.terminate( + 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; + } + 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 +1563,49 @@ static void validateApiKeyTypeAndExpiration( } } + private boolean validateCertificateIdentity(String certificateIdentity, String certificateIdentityPattern) { + logger.trace("Validating certificate identity [{}] against [{}]", certificateIdentity, certificateIdentityPattern); + return getCertificateIdentityPattern(certificateIdentityPattern).matcher(certificateIdentity).matches(); + } + + // Visible for testing + Pattern getCertificateIdentityPattern(String certificateIdentityPattern) { + if (certificateIdentityPatternCache != null) { + try { + return certificateIdentityPatternCache.computeIfAbsent(certificateIdentityPattern, Pattern::compile); + } catch (ExecutionException e) { + logger.error( + Strings.format( + "Failed to validate certificate identity against pattern [%s] using cache. Falling back to regular matching", + certificateIdentityPattern + ), + e + ); + } + } + return Pattern.compile(certificateIdentityPattern); + } + 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 +1629,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 +1747,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 +1791,15 @@ public void clearCredentials() { 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; + } } 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..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 @@ -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 { @@ -67,22 +69,23 @@ 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 signingInfo = crossClusterAccessHeaders.signature(); + + // 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) { 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 +123,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 +160,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 +188,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 +218,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 +3081,8 @@ public void testAuthenticationFailureWithApiKeyTypeMismatch() throws Exception { assertThat(service.getApiKeyAuthCache().keys(), contains(id)); } - public void testValidateApiKeyTypeAndExpiration() throws IOException { + public void testCompleteApiKeyAuthentication() throws IOException { + var apiKeyService = createApiKeyService(); final var apiKeyId = randomAlphaOfLength(12); final var apiKey = randomAlphaOfLength(16); final var hasher = getFastStoredHashAlgoForTests(); @@ -3100,7 +3103,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 +3124,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 +3141,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 +3213,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 +3231,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, notNullValue()); final Map updatedDoc = extractDocumentContent(builder); @@ -3253,7 +3254,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, nullValue()); } @@ -3274,7 +3275,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, notNullValue()); final Map updatedDoc = extractDocumentContent(builder); @@ -3297,7 +3298,7 @@ public void testMaybeBuildUpdatedDocumentCertificateIdentityHandling() throws Ex createTestAuthentication(), updateRequest, Set.of(), - mockClock + clock ); assertThat(builder, notNullValue()); final Map updatedDoc = extractDocumentContent(builder); @@ -3319,12 +3320,162 @@ 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<>(); + createApiKeyService().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); + + createApiKeyService().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); + + createApiKeyService().completeApiKeyAuthentication(apiKeyDoc, credentialsWithoutCertIdentity, clock, future); + + final AuthenticationResult result = future.get(); + assertThat(result, notNullValue()); + assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.TERMINATE)); + 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 { + 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), @@ -3385,7 +3536,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 +3585,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 +3735,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 +3810,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..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 @@ -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,26 @@ 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( + "API key (type:[cross_cluster], id:[" + + encodedCrossClusterAccessApiKeyWithId.id + + "]) requires certificate identity matching [CN=ins*], but no certificate was provided" + ) + ); + } } private Map withRandomizedAdditionalSecurityHeaders(Map headers) throws IOException { @@ -283,7 +351,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; + } + }