diff --git a/docs/changelog/134137.yaml b/docs/changelog/134137.yaml new file mode 100644 index 0000000000000..edcf6db58c9d2 --- /dev/null +++ b/docs/changelog/134137.yaml @@ -0,0 +1,5 @@ +pr: 134137 +summary: Add signing configuration for cross cluster api keys +area: Security +type: enhancement +issues: [] diff --git a/server/src/main/java/org/elasticsearch/common/settings/AbstractScopedSettings.java b/server/src/main/java/org/elasticsearch/common/settings/AbstractScopedSettings.java index 604f281a82310..e90db928a8323 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/AbstractScopedSettings.java +++ b/server/src/main/java/org/elasticsearch/common/settings/AbstractScopedSettings.java @@ -305,14 +305,18 @@ public void apply(Map> values, Settings current, Settings pr } /** - * Adds a affix settings consumer that accepts the settings for a group of settings. The consumer is only - * notified if at least one of the settings change. + * Adds an affix settings consumer and validator that accepts the settings for a group of settings. The consumer and + * the validator are only notified if at least one of the settings change. *

* Note: Only settings registered in {@link SettingsModule} can be changed dynamically. *

*/ @SuppressWarnings("rawtypes") - public synchronized void addAffixGroupUpdateConsumer(List> settings, BiConsumer consumer) { + public synchronized void addAffixGroupUpdateConsumer( + List> settings, + BiConsumer consumer, + BiConsumer validator + ) { List affixUpdaters = new ArrayList<>(settings.size()); for (Setting.AffixSetting setting : settings) { ensureSettingIsRegistered(setting); @@ -330,8 +334,8 @@ public boolean hasChanged(Settings current, Settings previous) { public Map getValue(Settings current, Settings previous) { Set namespaces = new HashSet<>(); for (Setting.AffixSetting setting : settings) { - SettingUpdater affixUpdaterA = setting.newAffixUpdater((k, v) -> namespaces.add(k), logger, (a, b) -> {}); - affixUpdaterA.apply(current, previous); + SettingUpdater affixUpdater = setting.newAffixUpdater((k, v) -> namespaces.add(k), logger, (a, b) -> {}); + affixUpdater.apply(current, previous); } Map namespaceToSettings = Maps.newMapWithExpectedSize(namespaces.size()); for (String namespace : namespaces) { @@ -339,7 +343,9 @@ public Map getValue(Settings current, Settings previous) { for (Setting.AffixSetting setting : settings) { concreteSettings.add(setting.getConcreteSettingForNamespace(namespace).getKey()); } - namespaceToSettings.put(namespace, current.filter(concreteSettings::contains)); + var subset = current.filter(concreteSettings::contains); + validator.accept(namespace, subset); + namespaceToSettings.put(namespace, subset); } return namespaceToSettings; } @@ -353,6 +359,17 @@ public void apply(Map values, Settings current, Settings previ }); } + /** + * Adds an affix settings consumer that accepts the settings for a group of settings. The consumer is only + * notified if at least one of the settings change. + *

+ * Note: Only settings registered in {@link SettingsModule} can be changed dynamically. + *

+ */ + public synchronized void addAffixGroupUpdateConsumer(List> settings, BiConsumer consumer) { + addAffixGroupUpdateConsumer(settings, consumer, (a, b) -> {}); + } + private void ensureSettingIsRegistered(Setting.AffixSetting setting) { final Setting registeredSetting = this.complexMatchers.get(setting.getKey()); if (setting != registeredSetting) { diff --git a/server/src/test/java/org/elasticsearch/common/settings/ScopedSettingsTests.java b/server/src/test/java/org/elasticsearch/common/settings/ScopedSettingsTests.java index 253abcf93dace..fd04040839fee 100644 --- a/server/src/test/java/org/elasticsearch/common/settings/ScopedSettingsTests.java +++ b/server/src/test/java/org/elasticsearch/common/settings/ScopedSettingsTests.java @@ -467,8 +467,14 @@ public void testAffixGroupUpdateConsumer() { String group2 = randomAlphaOfLength(4); String group3 = randomAlphaOfLength(5); BiConsumer listConsumer = results::put; + BiConsumer validator = (group, settings) -> { + var val = intSetting.getConcreteSettingForNamespace(group).get(settings); + if (val > 10) { + throw new IllegalArgumentException("int too large"); + } + }; - service.addAffixGroupUpdateConsumer(Arrays.asList(intSetting, listSetting), listConsumer); + service.addAffixGroupUpdateConsumer(Arrays.asList(intSetting, listSetting), listConsumer, validator); assertEquals(0, results.size()); service.applySettings( Settings.builder() @@ -541,6 +547,21 @@ public void testAffixGroupUpdateConsumer() { assertEquals(Arrays.asList(16, 17), listSetting.getConcreteSettingForNamespace(group1).get(groupOneSettings)); assertEquals(1, results.size()); assertEquals(2, groupOneSettings.size()); + + var exception = assertThrows( + IllegalArgumentException.class, + () -> service.applySettings( + Settings.builder() + .put(intBuilder.apply(group1), 2) + .put(intBuilder.apply(group2), 11) // fails validation + .putList(listBuilder.apply(group1), "16", "17") + .putList(listBuilder.apply(group3), "5", "6") + .build() + ) + ); + + assertThat(exception.getMessage(), containsString("int too large")); + results.clear(); } diff --git a/x-pack/plugin/security/build.gradle b/x-pack/plugin/security/build.gradle index 3dc27687d079f..28d8892fc961e 100644 --- a/x-pack/plugin/security/build.gradle +++ b/x-pack/plugin/security/build.gradle @@ -184,6 +184,7 @@ tasks.named("dependencyLicenses").configure { tasks.named("forbiddenPatterns").configure { exclude '**/*.key' + exclude '**/*.bcfks' exclude '**/*.p12' exclude '**/*.der' exclude '**/*.zip' diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerIntegTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerIntegTests.java new file mode 100644 index 0000000000000..94679ad236809 --- /dev/null +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerIntegTests.java @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.ssl.PemKeyConfig; +import org.elasticsearch.test.SecurityIntegTestCase; + +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_CERT_PATH; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_ALIAS; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_PATH; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_SECURE_PASSWORD; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_TYPE; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEY_PATH; +import static org.hamcrest.Matchers.equalToIgnoringCase; + +public class CrossClusterApiKeySignerIntegTests extends SecurityIntegTestCase { + + private static final String DYNAMIC_TEST_CLUSTER_ALIAS = "dynamic_test_cluster"; + private static final String STATIC_TEST_CLUSTER_ALIAS = "static_test_cluster"; + + public void testSignWithPemKeyConfig() { + final CrossClusterApiKeySigner signer = internalCluster().getInstance( + CrossClusterApiKeySigner.class, + internalCluster().getRandomNodeName() + ); + final String[] testHeaders = randomArray(5, String[]::new, () -> randomAlphanumericOfLength(randomInt(20))); + + X509CertificateSignature signature = signer.sign(STATIC_TEST_CLUSTER_ALIAS, testHeaders); + signature.certificate().getPublicKey(); + + var keyConfig = new PemKeyConfig( + "signing_rsa.crt", + "signing_rsa.key", + new char[0], + getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.crt").getParent() + ); + + assertThat(signature.algorithm(), equalToIgnoringCase(keyConfig.getKeys().getFirst().v2().getSigAlgName())); + assertEquals(signature.certificate(), keyConfig.getKeys().getFirst().v2()); + } + + public void testSignUnknownClusterAlias() { + final CrossClusterApiKeySigner signer = internalCluster().getInstance( + CrossClusterApiKeySigner.class, + internalCluster().getRandomNodeName() + ); + final String[] testHeaders = randomArray(5, String[]::new, () -> randomAlphanumericOfLength(randomInt(20))); + + X509CertificateSignature signature = signer.sign("unknowncluster", testHeaders); + assertNull(signature); + } + + public void testSeveralKeyStoreAliases() { + final CrossClusterApiKeySigner signer = internalCluster().getInstance( + CrossClusterApiKeySigner.class, + internalCluster().getRandomNodeName() + ); + + try { + // Create a new config without an alias. Since there are several aliases in the keystore, no signature should be generated + updateClusterSettings( + Settings.builder() + .put( + SIGNING_KEYSTORE_TYPE.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey(), + inFipsJvm() ? "BCFKS" : "PKCS12" + ) + .put( + SIGNING_KEYSTORE_PATH.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey(), + getDataPath("/org/elasticsearch/xpack/security/signature/signing." + (inFipsJvm() ? "bcfks" : "jks")) + ) + ); + + { + X509CertificateSignature signature = signer.sign(DYNAMIC_TEST_CLUSTER_ALIAS, "test", "test"); + assertNull(signature); + } + + // Add an alias from the keystore + updateClusterSettings( + Settings.builder() + .put(SIGNING_KEYSTORE_ALIAS.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey(), "wholelottakey") + ); + { + X509CertificateSignature signature = signer.sign(DYNAMIC_TEST_CLUSTER_ALIAS, "test", "test"); + assertNotNull(signature); + } + + // Add an alias not in the keystore, settings should silently fail to apply + updateClusterSettings( + Settings.builder() + .put(SIGNING_KEYSTORE_ALIAS.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey(), "idonotexist") + ); + { + X509CertificateSignature signature = signer.sign(DYNAMIC_TEST_CLUSTER_ALIAS, "test", "test"); + assertNotNull(signature); + } + } finally { + updateClusterSettings( + Settings.builder() + .putNull(SIGNING_KEYSTORE_PATH.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey()) + .putNull(SIGNING_KEYSTORE_ALIAS.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey()) + .putNull(SIGNING_KEYSTORE_TYPE.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey()) + .setSecureSettings(new MockSecureSettings()) + ); + } + } + + @Override + protected Settings nodeSettings(int nodeOrdinal, Settings otherSettings) { + var builder = Settings.builder(); + MockSecureSettings secureSettings = (MockSecureSettings) builder.put(super.nodeSettings(nodeOrdinal, otherSettings)) + .put( + SIGNING_CERT_PATH.getConcreteSettingForNamespace(STATIC_TEST_CLUSTER_ALIAS).getKey(), + getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.crt") + ) + .put( + SIGNING_KEY_PATH.getConcreteSettingForNamespace(STATIC_TEST_CLUSTER_ALIAS).getKey(), + getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.key") + ) + .getSecureSettings(); + secureSettings.setString( + SIGNING_KEYSTORE_SECURE_PASSWORD.getConcreteSettingForNamespace(DYNAMIC_TEST_CLUSTER_ALIAS).getKey(), + "secretpassword" + ); + return builder.build(); + } +} diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/transport/CrossClusterSigningConfigReloaderIntegTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/transport/CrossClusterSigningConfigReloaderIntegTests.java new file mode 100644 index 0000000000000..f3d57d0817553 --- /dev/null +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/transport/CrossClusterSigningConfigReloaderIntegTests.java @@ -0,0 +1,304 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.admin.cluster.node.reload.NodesReloadSecureSettingsRequest; +import org.elasticsearch.action.admin.cluster.node.reload.NodesReloadSecureSettingsResponse; +import org.elasticsearch.action.admin.cluster.node.reload.TransportNodesReloadSecureSettingsAction; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.test.SecurityIntegTestCase; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import javax.net.ssl.KeyManagerFactory; + +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_CERT_PATH; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_ALGORITHM; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_ALIAS; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_PATH; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_SECURE_PASSWORD; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEYSTORE_TYPE; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEY_PATH; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SIGNING_KEY_SECURE_PASSPHRASE; +import static org.hamcrest.Matchers.equalTo; + +public class CrossClusterSigningConfigReloaderIntegTests extends SecurityIntegTestCase { + + public void testAddAndRemoveClusterConfigsRuntime() throws Exception { + addAndRemoveClusterConfigsRuntime(randomClusterAliases(), clusterAlias -> { + updateClusterSettings( + Settings.builder() + .put( + SIGNING_CERT_PATH.getConcreteSettingForNamespace(clusterAlias).getKey(), + getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.crt") + ) + .put( + SIGNING_KEY_PATH.getConcreteSettingForNamespace(clusterAlias).getKey(), + getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.key") + ) + ); + }, clusterAlias -> { + updateClusterSettings( + Settings.builder() + .putNull(SIGNING_CERT_PATH.getConcreteSettingForNamespace(clusterAlias).getKey()) + .putNull(SIGNING_KEY_PATH.getConcreteSettingForNamespace(clusterAlias).getKey()) + ); + }); + } + + public void testAddSecureSettingsConfigRuntime() throws Exception { + addAndRemoveClusterConfigsRuntime(randomClusterAliases(), clusterAlias -> { + writeSecureSettingsToKeyStoreAndReload( + Map.of( + SIGNING_KEYSTORE_SECURE_PASSWORD.getConcreteSettingForNamespace(clusterAlias).getKey(), + "secretpassword".toCharArray() + ) + ); + updateClusterSettings( + Settings.builder() + .put( + SIGNING_KEYSTORE_ALGORITHM.getConcreteSettingForNamespace(clusterAlias).getKey(), + KeyManagerFactory.getDefaultAlgorithm() + ) + .put(SIGNING_KEYSTORE_ALIAS.getConcreteSettingForNamespace(clusterAlias).getKey(), "wholelottakey") + .put(SIGNING_KEYSTORE_TYPE.getConcreteSettingForNamespace(clusterAlias).getKey(), inFipsJvm() ? "BCFKS" : "PKCS12") + .put( + SIGNING_KEYSTORE_PATH.getConcreteSettingForNamespace(clusterAlias).getKey(), + getDataPath("/org/elasticsearch/xpack/security/signature/signing." + (inFipsJvm() ? "bcfks" : "jks")) + ) + ); + }, clusterAlias -> { + updateClusterSettings( + Settings.builder() + .putNull(SIGNING_KEYSTORE_PATH.getConcreteSettingForNamespace(clusterAlias).getKey()) + .putNull(SIGNING_KEYSTORE_TYPE.getConcreteSettingForNamespace(clusterAlias).getKey()) + .putNull(SIGNING_KEYSTORE_ALIAS.getConcreteSettingForNamespace(clusterAlias).getKey()) + .putNull(SIGNING_KEYSTORE_ALGORITHM.getConcreteSettingForNamespace(clusterAlias).getKey()) + .setSecureSettings(new MockSecureSettings()) + ); + removeSecureSettingsFromKeyStoreAndReload( + Set.of(SIGNING_KEYSTORE_SECURE_PASSWORD.getConcreteSettingForNamespace(clusterAlias).getKey()) + ); + }); + } + + public void testDependentKeyConfigFilesUpdated() throws Exception { + assumeFalse("Test credentials uses key encryption not supported in Fips JVM", inFipsJvm()); + final CrossClusterApiKeySigner signer = internalCluster().getInstance( + CrossClusterApiKeySigner.class, + internalCluster().getRandomNodeName() + ); + + String testClusterAlias = "test_cluster"; + + try { + // Write passphrase for ec key to keystore + writeSecureSettingsToKeyStoreAndReload( + Map.of(SIGNING_KEY_SECURE_PASSPHRASE.getConcreteSettingForNamespace(testClusterAlias).getKey(), "marshall".toCharArray()) + ); + + assertNull(signer.sign(testClusterAlias, "a_header")); + Path tempDir = createTempDir(); + Path signingCert = tempDir.resolve("signing.crt"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.crt"), signingCert); + Path signingKey = tempDir.resolve("signing.key"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.key"), signingKey); + + Path updatedSigningCert = tempDir.resolve("updated_signing.crt"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/signature/signing_ec.crt"), updatedSigningCert); + Path updatedSigningKey = tempDir.resolve("updated_signing.key"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/signature/signing_ec.key"), updatedSigningKey); + + // Add the cluster + updateClusterSettings( + Settings.builder() + .put(SIGNING_CERT_PATH.getConcreteSettingForNamespace(testClusterAlias).getKey(), signingCert) + .put(SIGNING_KEY_PATH.getConcreteSettingForNamespace(testClusterAlias).getKey(), signingKey) + ); + + // Make sure a signature can be created + var signatureBefore = signer.sign(testClusterAlias, "test", "test"); + assertNotNull(signatureBefore); + + Files.move(updatedSigningCert, signingCert, StandardCopyOption.REPLACE_EXISTING); + Files.move(updatedSigningKey, signingKey, StandardCopyOption.REPLACE_EXISTING); + + assertBusy(() -> { + var signatureAfter = signer.sign(testClusterAlias, "test", "test"); + assertNotNull(signatureAfter); + assertNotEquals(signatureAfter, signatureBefore); + }); + } finally { + updateClusterSettings( + Settings.builder() + .putNull(SIGNING_CERT_PATH.getConcreteSettingForNamespace(testClusterAlias).getKey()) + .putNull(SIGNING_KEY_PATH.getConcreteSettingForNamespace(testClusterAlias).getKey()) + .setSecureSettings(new MockSecureSettings()) + ); + removeSecureSettingsFromKeyStoreAndReload( + Set.of(SIGNING_KEYSTORE_SECURE_PASSWORD.getConcreteSettingForNamespace(testClusterAlias).getKey()) + ); + } + } + + public void testRemoveFileWithConfig() throws Exception { + try { + final CrossClusterApiKeySigner signer = internalCluster().getInstance( + CrossClusterApiKeySigner.class, + internalCluster().getRandomNodeName() + ); + + assertNull(signer.sign("test_cluster", "a_header")); + Path tempDir = createTempDir(); + Path signingCert = tempDir.resolve("signing.crt"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.crt"), signingCert); + Path signingKey = tempDir.resolve("signing.key"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/signature/signing_rsa.key"), signingKey); + + // Add the cluster + updateClusterSettings( + Settings.builder() + .put("cluster.remote.test_cluster.signing.certificate", signingCert) + .put("cluster.remote.test_cluster.signing.key", signingKey) + ); + + // Make sure a signature can be created + var signatureBefore = signer.sign("test_cluster", "test", "test"); + assertNotNull(signatureBefore); + + // This should just fail the update, not remove any actual configs + Files.delete(signingCert); + Files.delete(signingKey); + + var signatureAfter = signer.sign("test_cluster", "test", "test"); + assertNotNull(signatureAfter); + assertEquals(signatureAfter, signatureBefore); + } finally { + updateClusterSettings( + Settings.builder() + .putNull("cluster.remote.test_cluster.signing.certificate") + .putNull("cluster.remote.test_cluster.signing.key") + .setSecureSettings(new MockSecureSettings()) + ); + } + } + + public void testValidationFailsWhenUpdateWithInvalidPath() throws Exception { + var exception = assertThrows( + IllegalArgumentException.class, + () -> updateClusterSettings( + Settings.builder() + .put(SIGNING_CERT_PATH.getConcreteSettingForNamespace("test").getKey(), "/unknown/path") + .put(SIGNING_KEY_PATH.getConcreteSettingForNamespace("test").getKey(), "/unknown/path") + ) + ); + assertThat(exception.getMessage(), equalTo("File [/unknown/path] configured for remote cluster [test] does no exist")); + } + + private void addAndRemoveClusterConfigsRuntime( + Set clusterAliases, + Consumer clusterCreator, + Consumer clusterRemover + ) throws Exception { + final CrossClusterApiKeySigner signer = internalCluster().getInstance( + CrossClusterApiKeySigner.class, + internalCluster().getRandomNodeName() + ); + final String[] testHeaders = randomArray(5, String[]::new, () -> randomAlphanumericOfLength(randomInt(20))); + + try { + for (var clusterAlias : clusterAliases) { + // Try to create a signature for a remote cluster that doesn't exist + assertNull(signer.sign(clusterAlias, testHeaders)); + clusterCreator.accept(clusterAlias); + // Make sure a signature can be created + assertNotNull(signer.sign(clusterAlias, testHeaders)); + } + for (var clusterAlias : clusterAliases) { + clusterRemover.accept(clusterAlias); + // Make sure no signature was created + assertBusy(() -> assertNull(signer.sign(clusterAlias, testHeaders))); + } + } finally { + var builder = Settings.builder(); + for (var clusterAlias : clusterAliases) { + CrossClusterApiKeySignerSettings.getDynamicSettings().forEach(setting -> { + builder.putNull(setting.getConcreteSettingForNamespace(clusterAlias).getKey()); + }); + } + if (clusterAliases.isEmpty() == false) { + updateClusterSettings(builder.setSecureSettings(new MockSecureSettings())); + } + } + } + + private Set randomClusterAliases() { + return randomUnique(() -> randomAlphaOfLengthBetween(1, randomIntBetween(5, 20)), randomInt(5)); + } + + private void writeSecureSettingsToKeyStoreAndReload(Map entries) { + char[] keyStorePassword = randomAlphaOfLengthBetween(15, randomIntBetween(15, 20)).toCharArray(); + internalCluster().getInstances(Environment.class).forEach(environment -> { + final KeyStoreWrapper keyStoreWrapper = KeyStoreWrapper.create(); + entries.forEach(keyStoreWrapper::setString); + try { + keyStoreWrapper.save(environment.configDir(), keyStorePassword, false); + logger.info(keyStoreWrapper.toString()); + } catch (Exception e) { + fail(e.getMessage()); + } + }); + PlainActionFuture future = new PlainActionFuture<>(); + reloadSecureSettings(keyStorePassword, future); + future.actionGet(); + } + + private void removeSecureSettingsFromKeyStoreAndReload(Set settingsToRemove) { + char[] keyStorePassword = randomAlphaOfLengthBetween(15, randomIntBetween(15, 20)).toCharArray(); + internalCluster().getInstances(Environment.class).forEach(environment -> { + final KeyStoreWrapper keyStoreWrapper = KeyStoreWrapper.create(); + settingsToRemove.forEach(keyStoreWrapper::remove); + try { + keyStoreWrapper.save(environment.configDir(), keyStorePassword, false); + logger.info(keyStoreWrapper.toString()); + } catch (Exception e) { + fail(e.getMessage()); + } + }); + PlainActionFuture future = new PlainActionFuture<>(); + reloadSecureSettings(keyStorePassword, future); + future.actionGet(); + } + + private static void reloadSecureSettings(char[] password, ActionListener listener) { + final var request = new NodesReloadSecureSettingsRequest(new String[0]); + try { + request.setSecureStorePassword(new SecureString(password)); + clusterAdmin().execute(TransportNodesReloadSecureSettingsAction.TYPE, request, listener); + } finally { + request.decRef(); + } + } + + @Override + public boolean transportSSLEnabled() { + // Needs to be enabled to allow updates to secure settings + return true; + } +} 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 c9f0771e93068..e2356d0d90478 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 @@ -418,6 +418,9 @@ import org.elasticsearch.xpack.security.support.SecurityMigrations; import org.elasticsearch.xpack.security.support.SecuritySystemIndices; import org.elasticsearch.xpack.security.transport.CrossClusterAccessTransportInterceptor; +import org.elasticsearch.xpack.security.transport.CrossClusterApiKeySigner; +import org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings; +import org.elasticsearch.xpack.security.transport.CrossClusterApiKeySigningConfigReloader; import org.elasticsearch.xpack.security.transport.RemoteClusterTransportInterceptor; import org.elasticsearch.xpack.security.transport.SecurityHttpSettings; import org.elasticsearch.xpack.security.transport.SecurityServerTransportInterceptor; @@ -1180,6 +1183,18 @@ Collection createComponents( crossClusterAccessAuthcService.get(), getLicenseState() ); + + var crossClusterApiKeySignerReloader = new CrossClusterApiKeySigningConfigReloader( + environment, + resourceWatcherService, + clusterService.getClusterSettings() + ); + components.add(crossClusterApiKeySignerReloader); + + var crossClusterApiKeySigner = new CrossClusterApiKeySigner(environment); + crossClusterApiKeySignerReloader.setApiKeySigner(crossClusterApiKeySigner); + components.add(crossClusterApiKeySigner); + securityInterceptor.set( new SecurityServerTransportInterceptor( settings, @@ -1557,6 +1572,7 @@ public static List> getSettings(List securityExten settingsList.add(CachingServiceAccountTokenStore.CACHE_MAX_TOKENS_SETTING); settingsList.add(SimpleRole.CACHE_SIZE_SETTING); settingsList.add(NativeRoleMappingStore.LAST_LOAD_CACHE_ENABLED_SETTING); + settingsList.addAll(CrossClusterApiKeySignerSettings.getSettings()); // hide settings settingsList.add(Setting.stringListSetting(SecurityField.setting("hide_settings"), Property.NodeScope, Property.Filtered)); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigner.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigner.java new file mode 100644 index 0000000000000..a895d848b43bc --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigner.java @@ -0,0 +1,215 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.ssl.SslKeyConfig; +import org.elasticsearch.common.ssl.SslUtil; +import org.elasticsearch.env.Environment; +import org.elasticsearch.logging.LogManager; +import org.elasticsearch.logging.Logger; +import org.elasticsearch.xpack.core.ssl.CertParsingUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import javax.net.ssl.X509KeyManager; + +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.KEYSTORE_ALIAS_SUFFIX; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SETTINGS_PART_SIGNING; + +public class CrossClusterApiKeySigner { + private static final Map SIGNATURE_ALGORITHM_BY_TYPE = Map.of("RSA", "SHA256withRSA", "EC", "SHA256withECDSA"); + + private final Logger logger = LogManager.getLogger(getClass()); + private final Environment environment; + private final Map signingConfigByClusterAlias = new ConcurrentHashMap<>(); + + public CrossClusterApiKeySigner(Environment environment) { + this.environment = environment; + loadSigningConfigs(); + } + + Optional loadSigningConfig(String clusterAlias, Settings settings) { + logger.trace("Loading signing config for [{}] with settings [{}]", clusterAlias, settings); + if (settings.getByPrefix(SETTINGS_PART_SIGNING).isEmpty() == false) { + try { + SslKeyConfig keyConfig = CertParsingUtils.createKeyConfig(settings, SETTINGS_PART_SIGNING + ".", environment, false); + if (keyConfig.hasKeyMaterial()) { + String alias = settings.get(SETTINGS_PART_SIGNING + "." + KEYSTORE_ALIAS_SUFFIX); + X509KeyManager keyManager = keyConfig.createKeyManager(); + if (keyManager == null) { + throw new IllegalStateException("Cannot create key manager for key config [" + keyConfig + "]"); + } + + var keyPair = Strings.isNullOrEmpty(alias) + ? buildKeyPair(keyManager, keyConfig) + : buildKeyPair(keyManager, keyConfig, alias); + + logger.trace("Key pair [{}] found for [{}]", keyPair, clusterAlias); + var signingConfig = new SigningConfig(keyPair, keyConfig.getDependentFiles()); + signingConfigByClusterAlias.put(clusterAlias, signingConfig); + return Optional.of(signingConfig); + } else { + logger.error(Strings.format("No signing credentials found in signing config for cluster [%s]", clusterAlias)); + } + } catch (Exception e) { + throw new IllegalStateException(Strings.format("Failed to load signing config for cluster [%s]", clusterAlias), e); + } + } + logger.trace("No valid signing config settings found for [{}] with settings [{}]", clusterAlias, settings); + signingConfigByClusterAlias.remove(clusterAlias); + return Optional.empty(); + } + + public void validateSigningConfigUpdate(String clusterAlias, Settings settings) { + if (settings.getByPrefix(SETTINGS_PART_SIGNING).isEmpty() == false) { + var keyConfig = CertParsingUtils.createKeyConfig(settings, SETTINGS_PART_SIGNING + ".", environment, false); + keyConfig.getDependentFiles().stream().forEach(file -> { + if (Files.exists(file) == false) { + throw new IllegalArgumentException( + Strings.format("File [%s] configured for remote cluster [%s] does no exist", file, clusterAlias) + ); + } + }); + } + } + + public X509CertificateSignature sign(String clusterAlias, String... headers) { + SigningConfig signingConfig = signingConfigByClusterAlias.get(clusterAlias); + if (signingConfig == null) { + logger.trace("No signing config found for [{}] returning empty signature", clusterAlias); + return null; + } + var keyPair = signingConfig.keyPair(); + try { + String algorithm = keyPair.signatureAlgorithm(); + Signature signature = Signature.getInstance(algorithm); + signature.initSign(keyPair.privateKey); + signature.update(getSignableBytes(headers)); + final byte[] sigBytes = signature.sign(); + return new X509CertificateSignature(keyPair.certificate, algorithm, new BytesArray(sigBytes)); + } catch (GeneralSecurityException e) { + throw new ElasticsearchSecurityException( + Strings.format("Failed to sign cross cluster headers for cluster [%s]", clusterAlias), + e + ); + } + } + + private void loadSigningConfigs() { + this.environment.settings().getGroups("cluster.remote.", true).forEach(this::loadSigningConfig); + } + + private X509KeyPair buildKeyPair(X509KeyManager keyManager, SslKeyConfig keyConfig) { + final Set aliases = SIGNATURE_ALGORITHM_BY_TYPE.keySet() + .stream() + .map(keyType -> keyManager.getServerAliases(keyType, null)) + .filter(Objects::nonNull) + .flatMap(Arrays::stream) + .collect(Collectors.toSet()); + + logger.trace("KeyConfig [{}] has compatible entries: [{}]", keyConfig, aliases); + + return switch (aliases.size()) { + case 0 -> throw new IllegalStateException("Cannot find a signing key in [" + keyConfig + "]"); + case 1 -> { + final String aliasFromKeyStore = aliases.iterator().next(); + final X509Certificate[] chain = keyManager.getCertificateChain(aliasFromKeyStore); + yield new X509KeyPair(chain[0], keyManager.getPrivateKey(aliasFromKeyStore)); + } + default -> throw new IllegalStateException( + "The configured signing key store has multiple signing keys [" + + aliases + + "] but no alias has been specified in signing configuration." + ); + }; + } + + private X509KeyPair buildKeyPair(X509KeyManager keyManager, SslKeyConfig keyConfig, String alias) { + assert alias != null; + + final String keyType = keyManager.getPrivateKey(alias).getAlgorithm(); + if (SIGNATURE_ALGORITHM_BY_TYPE.containsKey(keyType) == false) { + throw new IllegalStateException( + Strings.format( + "The key associated with alias [%s] uses unsupported key algorithm type [%s], only %s is supported", + alias, + keyType, + SIGNATURE_ALGORITHM_BY_TYPE.keySet() + ) + ); + } + + final X509Certificate[] chain = keyManager.getCertificateChain(alias); + logger.trace("KeyConfig [{}] has entry for alias: [{}] [{}]", keyConfig, alias, chain != null); + if (chain == null) { + throw new IllegalStateException("Key config missing certificate chain for alias [" + alias + "]"); + } + + return new X509KeyPair(chain[0], keyManager.getPrivateKey(alias)); + } + + private static byte[] getSignableBytes(final String... headers) { + return String.join("\n", headers).getBytes(StandardCharsets.UTF_8); + } + + private static String calculateFingerprint(X509Certificate certificate) { + try { + return SslUtil.calculateFingerprint(certificate, "SHA-1"); + } catch (CertificateEncodingException e) { + return ""; + } + } + + // visible for testing + record X509KeyPair(X509Certificate certificate, PrivateKey privateKey, String signatureAlgorithm, String fingerprint) { + X509KeyPair(X509Certificate certificate, PrivateKey privateKey) { + this( + Objects.requireNonNull(certificate), + Objects.requireNonNull(privateKey), + Optional.ofNullable(SIGNATURE_ALGORITHM_BY_TYPE.get(privateKey.getAlgorithm())) + .orElseThrow( + () -> new IllegalArgumentException( + "Unsupported Key Type [" + + privateKey.getAlgorithm() + + "] in private key for [" + + certificate.getSubjectX500Principal() + + "]" + ) + ), + calculateFingerprint(certificate) + ); + } + } + + record SigningConfig(X509KeyPair keyPair, Collection dependentFiles) { + public SigningConfig { + Objects.requireNonNull(keyPair); + Objects.requireNonNull(dependentFiles); + } + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerSettings.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerSettings.java new file mode 100644 index 0000000000000..eac190ff7b209 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerSettings.java @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.common.settings.SecureSetting; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.ssl.SslConfigurationKeys; + +import java.util.ArrayList; +import java.util.List; + +import javax.net.ssl.KeyManagerFactory; + +public class CrossClusterApiKeySignerSettings { + static final String SETTINGS_PART_SIGNING = "signing"; + + static final String KEYSTORE_ALIAS_SUFFIX = "keystore.alias"; + + static final Setting.AffixSetting SIGNING_KEYSTORE_ALIAS = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + KEYSTORE_ALIAS_SUFFIX, + key -> Setting.simpleString(key, newKey -> { + + }, Setting.Property.NodeScope, Setting.Property.Filtered, Setting.Property.Dynamic) + ); + + static final Setting.AffixSetting SIGNING_KEYSTORE_PATH = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEYSTORE_PATH, + key -> Setting.simpleString(key, Setting.Property.NodeScope, Setting.Property.Filtered, Setting.Property.Dynamic) + ); + + static final Setting.AffixSetting SIGNING_KEYSTORE_SECURE_PASSWORD = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEYSTORE_SECURE_PASSWORD, + key -> SecureSetting.secureString(key, null) + ); + + static final Setting.AffixSetting SIGNING_KEYSTORE_ALGORITHM = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEYSTORE_ALGORITHM, + key -> Setting.simpleString( + key, + KeyManagerFactory.getDefaultAlgorithm(), + Setting.Property.NodeScope, + Setting.Property.Filtered, + Setting.Property.Dynamic + ) + ); + + static final Setting.AffixSetting SIGNING_KEYSTORE_TYPE = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEYSTORE_TYPE, + key -> Setting.simpleString(key, "", Setting.Property.NodeScope, Setting.Property.Filtered, Setting.Property.Dynamic) + ); + + static final Setting.AffixSetting SIGNING_KEYSTORE_SECURE_KEY_PASSWORD = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEYSTORE_SECURE_KEY_PASSWORD, + key -> SecureSetting.secureString(key, null) + ); + + static final Setting.AffixSetting SIGNING_KEY_PATH = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEY, + key -> Setting.simpleString(key, Setting.Property.NodeScope, Setting.Property.Filtered, Setting.Property.Dynamic) + ); + + static final Setting.AffixSetting SIGNING_KEY_SECURE_PASSPHRASE = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.KEY_SECURE_PASSPHRASE, + key -> SecureSetting.secureString(key, null) + ); + + static final Setting.AffixSetting SIGNING_CERT_PATH = Setting.affixKeySetting( + "cluster.remote.", + SETTINGS_PART_SIGNING + "." + SslConfigurationKeys.CERTIFICATE, + key -> Setting.simpleString(key, Setting.Property.NodeScope, Setting.Property.Filtered, Setting.Property.Dynamic) + ); + + public static List> getDynamicSettings() { + return List.of( + SIGNING_KEYSTORE_ALIAS, + SIGNING_KEYSTORE_PATH, + SIGNING_KEYSTORE_ALGORITHM, + SIGNING_KEYSTORE_TYPE, + SIGNING_KEY_PATH, + SIGNING_CERT_PATH + ); + } + + public static List> getSecureSettings() { + return List.of(SIGNING_KEYSTORE_SECURE_PASSWORD, SIGNING_KEYSTORE_SECURE_KEY_PASSWORD, SIGNING_KEY_SECURE_PASSPHRASE); + } + + public static List> getSettings() { + List> settings = new ArrayList<>(getSecureSettings()); + settings.addAll(getDynamicSettings()); + return settings; + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigningConfigReloader.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigningConfigReloader.java new file mode 100644 index 0000000000000..e67085ef333a8 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigningConfigReloader.java @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +package org.elasticsearch.xpack.security.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.InMemoryClonedSecureSettings; +import org.elasticsearch.common.settings.SecureSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.ssl.SslKeyConfig; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.core.Strings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.watcher.FileChangesListener; +import org.elasticsearch.watcher.FileWatcher; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.watcher.ResourceWatcherService.Frequency; +import org.elasticsearch.xpack.core.ssl.CertParsingUtils; +import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent; + +import java.io.IOException; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.SETTINGS_PART_SIGNING; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.getDynamicSettings; +import static org.elasticsearch.xpack.security.transport.CrossClusterApiKeySignerSettings.getSecureSettings; + +/** + * Responsible for reloading a provided {@link CrossClusterApiKeySigner} when updates are received from the following sources: + * - Dynamic cluster settings + * - Reloadable secure settings + * - File changes in any of the files pointed to by the cluster settings + */ +public final class CrossClusterApiKeySigningConfigReloader implements ReloadableSecurityComponent { + + private static final Logger logger = LogManager.getLogger(CrossClusterApiKeySigningConfigReloader.class); + private final Map monitoredPathToChangeListener = new ConcurrentHashMap<>(); + private final ResourceWatcherService resourceWatcherService; + private final Map settingsByClusterAlias = new ConcurrentHashMap<>(); + + private final PlainActionFuture crossClusterApiKeySignerFuture = new PlainActionFuture<>() { + @Override + protected boolean blockingAllowed() { + return true; // waits on the scheduler thread, once, and not for long + } + }; + + public CrossClusterApiKeySigningConfigReloader( + Environment environment, + ResourceWatcherService resourceWatcherService, + ClusterSettings clusterSettings + ) { + this.resourceWatcherService = resourceWatcherService; + settingsByClusterAlias.putAll(environment.settings().getGroups("cluster.remote.", true)); + watchDependentFilesForClusterAliases(resourceWatcherService, getInitialFilesToMonitor(environment)); + clusterSettings.addAffixGroupUpdateConsumer(getDynamicSettings(), (key, val) -> { + reloadConsumer(key, val.getByPrefix("cluster.remote." + key + "."), false); + logger.info("Updated signing configuration for [{}] due to updated cluster settings", key); + }, this::validateUpdate); + } + + private void validateUpdate(String clusterAlias, Settings settings) { + try { + var apiKeySigner = crossClusterApiKeySignerFuture.get(); + apiKeySigner.validateSigningConfigUpdate(clusterAlias, settings.getByPrefix("cluster.remote." + clusterAlias + ".")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + throw new ElasticsearchException("Failed to obtain crossClusterApiKeySigner", e); + } catch (Exception e) { + logger.debug( + Strings.format("Failed to update cluster [%s] with settings [%s] due validation error [%s]", clusterAlias, settings, e) + ); + throw e; + } + } + + private void reloadConsumer(String clusterAlias, @Nullable Settings settings, boolean updateSecureSettings) { + try { + var apiKeySigner = crossClusterApiKeySignerFuture.get(); + settingsByClusterAlias.compute(clusterAlias, (key, val) -> { + var effectiveSettings = buildEffectiveSettings(val, settings, updateSecureSettings); + try { + var signingConfig = apiKeySigner.loadSigningConfig(clusterAlias, effectiveSettings); + signingConfig.ifPresent( + config -> watchDependentFilesForClusterAliases( + resourceWatcherService, + config.dependentFiles().stream().collect(Collectors.toMap(file -> file, (file) -> Set.of(clusterAlias))) + ) + ); + } catch (IllegalStateException e) { + logger.error(Strings.format("Failed to load signing config for cluster [%s]", clusterAlias), e); + } + return effectiveSettings; + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + throw new ElasticsearchException("Failed to obtain crossClusterApiKeySigner", e); + } + } + + public void setApiKeySigner(CrossClusterApiKeySigner apiKeySigner) { + assert crossClusterApiKeySignerFuture.isDone() == false : "apiKeySigner already set"; + crossClusterApiKeySignerFuture.onResponse(apiKeySigner); + } + + private Map> getInitialFilesToMonitor(Environment environment) { + Map> filesToMonitor = new HashMap<>(); + this.settingsByClusterAlias.forEach((clusterAlias, settingsForCluster) -> { + SslKeyConfig keyConfig = CertParsingUtils.createKeyConfig(settingsForCluster, SETTINGS_PART_SIGNING + ".", environment, false); + for (Path path : keyConfig.getDependentFiles()) { + filesToMonitor.compute( + path, + (p, aliases) -> aliases == null ? Set.of(clusterAlias) : Sets.addToCopy(aliases, clusterAlias) + ); + } + }); + return filesToMonitor; + } + + private void watchDependentFilesForClusterAliases( + ResourceWatcherService resourceWatcherService, + Map> dependentFilesToClusterAliases + ) { + dependentFilesToClusterAliases.forEach((path, clusterAliases) -> { + monitoredPathToChangeListener.compute(path, (monitoredPath, existingChangeListener) -> { + if (existingChangeListener != null) { + logger.trace("Found existing listener for file [{}], adding clusterAliases {}", path, clusterAliases); + existingChangeListener.addClusterAliases(clusterAliases); + return existingChangeListener; + } + + logger.trace("Adding listener for file [{}] for clusters {}", path, clusterAliases); + ChangeListener changeListener = new ChangeListener( + new HashSet<>(clusterAliases), + path, + (clusterAlias) -> this.reloadConsumer(clusterAlias, null, false) + ); + FileWatcher fileWatcher = new FileWatcher(path); + fileWatcher.addListener(changeListener); + try { + resourceWatcherService.add(fileWatcher, Frequency.HIGH); + return changeListener; + } catch (IOException | SecurityException e) { + logger.error(Strings.format("failed to start watching file [%s]", path), e); + } + return changeListener; + }); + }); + } + + private record ChangeListener(Set clusterAliases, Path file, Consumer reloadConsumer) implements FileChangesListener { + public void addClusterAliases(Set clusterAliases) { + this.clusterAliases.addAll(clusterAliases); + } + + @Override + public void onFileCreated(Path file) { + onFileChanged(file); + } + + @Override + public void onFileDeleted(Path file) { + onFileChanged(file); + } + + @Override + public void onFileChanged(Path file) { + if (this.file.equals(file)) { + this.clusterAliases.forEach(reloadConsumer); + logger.info("Updated signing configuration for [{}] config(s) due to update of file [{}]", clusterAliases.size(), file); + } + } + } + + /** + * Build the effective remote cluster settings by merging the currently configured (if any) and new/updated settings + *

+ * - If newSettings is null - use existing settings, used to refresh the dependent files + * - If newSettings is empty - return empty settings, used for resetting signing config + * - If updateSecureSettings is true - merge secure settings from newSettings with current settings, used by secure settings refresh + * - If updateSecureSettings is false - merge new settings with existing secure settings, used for regular settings update + */ + private Settings buildEffectiveSettings( + @Nullable Settings currentSettings, + @Nullable Settings newSettings, + boolean updateSecureSettings + ) { + if (currentSettings == null) { + return newSettings == null ? Settings.EMPTY : newSettings; + } + if (newSettings == null) { + return currentSettings; + } + if (newSettings.isEmpty()) { + return Settings.EMPTY; + } + + Settings secureSettingsSource = updateSecureSettings ? newSettings : currentSettings; + Settings settingsSource = updateSecureSettings ? currentSettings : newSettings; + + SecureSettings secureSettings = Settings.builder().put(secureSettingsSource, true).getSecureSettings(); + + var builder = Settings.builder().put(settingsSource, false); + if (secureSettings != null) { + builder.setSecureSettings(secureSettings); + } + return builder.build(); + } + + @Override + public void reload(Settings settings) { + try { + // The secure settings provided to reload are only available in the scope of this method call since after that the keystore is + // closed. Since the secure settings will potentially be used later when the signing config is used to sign headers, the + // settings need to be retrieved from the keystore and cached + Settings cachedSettings = Settings.builder() + .setSecureSettings(InMemoryClonedSecureSettings.cloneSecureSettings(settings, getSecureSettings())) + .build(); + cachedSettings.getGroups("cluster.remote.", true).forEach((clusterAlias, settingsForCluster) -> { + // Only update signing config if settings were found, since empty signing config settings means config deletion + if (settingsForCluster.getByPrefix(SETTINGS_PART_SIGNING).isEmpty() == false) { + reloadConsumer(clusterAlias, settingsForCluster, true); + logger.info("Updated signing configuration for [{}] due to reload of secure settings", clusterAlias); + } + }); + } catch (GeneralSecurityException e) { + logger.error("Keystore exception while reloading signing configuration after reload of secure settings", e); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/X509CertificateSignature.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/X509CertificateSignature.java new file mode 100644 index 0000000000000..91e99f9c3a204 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/X509CertificateSignature.java @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.ssl.SslUtil; +import org.elasticsearch.core.CheckedConsumer; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.logging.LogManager; +import org.elasticsearch.logging.Logger; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Objects; + +public final class X509CertificateSignature implements Writeable { + + private static final Logger logger = LogManager.getLogger(X509CertificateSignature.class); + + private final X509Certificate certificate; + private final String algorithm; + private final BytesReference signature; + + public X509CertificateSignature(X509Certificate certificate, String algorithm, BytesReference signature) { + this.certificate = Objects.requireNonNull(certificate); + this.algorithm = Objects.requireNonNull(algorithm); + this.signature = Objects.requireNonNull(signature); + } + + public X509CertificateSignature(StreamInput in) throws IOException { + final byte[] certBytes = in.readByteArray(); + if (certBytes == null || certBytes.length == 0) { + throw new IOException("Certificate bytes cannot be empty"); + } + try (var bais = new ByteArrayInputStream(certBytes)) { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + final Certificate cert = certFactory.generateCertificate(bais); + if (cert instanceof X509Certificate x509) { + this.certificate = x509; + } else { + throw new IOException("Input bytes are not an X509 certificate [" + cert.getClass() + "] [" + cert + "]"); + } + } catch (CertificateException e) { + throw new IOException("Cannot read certificate", e); + } + this.algorithm = in.readString(); + this.signature = in.readBytesReference(); + } + + public X509Certificate certificate() { + return certificate; + } + + public String algorithm() { + return algorithm; + } + + public BytesReference signature() { + return signature; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (X509CertificateSignature) obj; + return Objects.equals(this.certificate, that.certificate) + && Objects.equals(this.algorithm, that.algorithm) + && Objects.equals(this.signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hash(certificate, algorithm, signature); + } + + @Override + public String toString() { + return "X509CertificateSignature[" + + "certificate=(" + + certificate.getSubjectX500Principal() + + ";" + + certificate.getType() + + ";" + + fingerprint() + + "), " + + "algorithm=" + + algorithm + + ", " + + "signature=" + + signature + + ']'; + } + + private String fingerprint() { + try { + return "SHA1:" + SslUtil.calculateFingerprint(this.certificate, "SHA-1"); + } catch (CertificateEncodingException e) { + return ""; + } + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + try { + final byte[] encoded = certificate.getEncoded(); + out.writeByteArray(encoded); + } catch (CertificateEncodingException e) { + throw new IOException("Cannot convert certificate for " + certificate.getSubjectX500Principal() + " to bytes", e); + } + out.writeString(algorithm); + out.writeBytesReference(signature); + } + + public String encodeToString() throws IOException { + final String encoded = encode(this); + logger.trace("Encoding {} as [{}]", this, encoded); + return encoded; + } + + public static X509CertificateSignature decode(String encoded) throws IOException { + logger.trace("Decoding [{}]", encoded); + try { + return decode(encoded, X509CertificateSignature::new); + } catch (IOException e) { + logger.debug("Failed to decode signature", e); + throw e; + } + } + + public static String encode(Writeable writeable) throws IOException { + return encode(TransportVersion.current(), writeable::writeTo); + } + + public static String encode(TransportVersion transportVersion, CheckedConsumer body) throws IOException { + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.setTransportVersion(transportVersion); + TransportVersion.writeVersion(transportVersion, out); + body.accept(out); + out.flush(); + return Base64.getEncoder().encodeToString(BytesReference.toBytes(out.bytes())); + } + } + + public static T decode(String encoded, CheckedFunction body) throws IOException { + Objects.requireNonNull(encoded); + final byte[] bytes = Base64.getDecoder().decode(encoded); + final StreamInput in = StreamInput.wrap(bytes); + final TransportVersion transportVersion = TransportVersion.readVersion(in); + in.setTransportVersion(transportVersion); + return body.apply(in); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/signature/X509CertificateSignatureTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/signature/X509CertificateSignatureTests.java new file mode 100644 index 0000000000000..7f2a69416a9ef --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/signature/X509CertificateSignatureTests.java @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.signature; + +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.ssl.PemUtils; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.security.transport.X509CertificateSignature; +import org.hamcrest.Matchers; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; + +public class X509CertificateSignatureTests extends ESTestCase { + + public void testEncodeDecode() throws Exception { + final List certificates = PemUtils.readCertificates(List.of(getResourceDataPath(getClass(), "signing_rsa.crt"))); + assertThat(certificates, hasSize(1)); + final BytesReference bytes = randomBytesReference(randomIntBetween(8, 50)); + final X509CertificateSignature original = new X509CertificateSignature( + (X509Certificate) certificates.get(0), + "SHA256withRSA", + bytes + ); + + final String encoded = original.encodeToString(); + final X509CertificateSignature decoded = X509CertificateSignature.decode(encoded); + + assertThat(decoded, Matchers.equalTo(original)); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerTests.java new file mode 100644 index 0000000000000..abde62f49304b --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySignerTests.java @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.node.Node; +import org.elasticsearch.test.ESTestCase; + +import static org.hamcrest.Matchers.equalTo; + +public class CrossClusterApiKeySignerTests extends ESTestCase { + + public void testLoadKeystore() { + var builder = Settings.builder() + .put("cluster.remote.my_remote.signing.keystore.alias", "wholelottakey") + .put("path.home", createTempDir()) + .put(Node.NODE_NAME_SETTING.getKey(), randomAlphaOfLengthBetween(3, 8)); + addKeyStorePathToBuilder("my_remote", builder); + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString("cluster.remote.my_remote.signing.keystore.secure_password", "secretpassword"); + builder.setSecureSettings(secureSettings); + var signer = new CrossClusterApiKeySigner(TestEnvironment.newEnvironment(builder.build())); + + assertNotNull(signer.sign("my_remote", "a_header")); + } + + public void testLoadKeystoreMissingFile() { + var builder = Settings.builder() + .put("cluster.remote.my_remote.signing.keystore.alias", "wholelottakey") + .put("cluster.remote.my_remote.signing.keystore.path", "not_a_valid_path") + .put("path.home", createTempDir()) + .put(Node.NODE_NAME_SETTING.getKey(), randomAlphaOfLengthBetween(3, 8)); + + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString("cluster.remote.my_remote.signing.keystore.secure_password", "secretpassword"); + builder.setSecureSettings(secureSettings); + var exception = assertThrows( + IllegalStateException.class, + () -> new CrossClusterApiKeySigner(TestEnvironment.newEnvironment(builder.build())) + ); + assertThat(exception.getMessage(), equalTo("Failed to load signing config for cluster [my_remote]")); + + } + + public void testLoadSeveralAliasesWithoutAliasSettingKeystore() { + var builder = Settings.builder() + .put("path.home", createTempDir()) + .put(Node.NODE_NAME_SETTING.getKey(), randomAlphaOfLengthBetween(3, 8)); + + addKeyStorePathToBuilder("my_remote", builder); + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString("cluster.remote.my_remote.signing.keystore.secure_password", "secretpassword"); + builder.setSecureSettings(secureSettings); + var exception = assertThrows( + IllegalStateException.class, + () -> new CrossClusterApiKeySigner(TestEnvironment.newEnvironment(builder.build())) + ); + assertThat(exception.getMessage(), equalTo("Failed to load signing config for cluster [my_remote]")); + } + + private void addKeyStorePathToBuilder(String remoteCluster, Settings.Builder builder) { + builder.put("cluster.remote." + remoteCluster + ".signing.keystore.type", inFipsJvm() ? "BCFKS" : "PKCS12") + .put( + "cluster.remote." + remoteCluster + ".signing.keystore.path", + getDataPath("/org/elasticsearch/xpack/security/signature/signing." + (inFipsJvm() ? "bcfks" : "jks")) + ); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigningConfigReloaderTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigningConfigReloaderTests.java new file mode 100644 index 0000000000000..be0cfbb62f519 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/transport/CrossClusterApiKeySigningConfigReloaderTests.java @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +package org.elasticsearch.xpack.security.transport; + +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.TimeValue; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.node.Node; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.junit.After; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PrivateKey; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CrossClusterApiKeySigningConfigReloaderTests extends ESTestCase { + private CrossClusterApiKeySigner crossClusterApiKeySigner; + private ResourceWatcherService resourceWatcherService; + private ThreadPool threadPool; + private Settings.Builder settingsBuilder; + private CrossClusterApiKeySigner.X509KeyPair testKeyPair; + + @Override + public void setUp() throws Exception { + super.setUp(); + crossClusterApiKeySigner = mock(CrossClusterApiKeySigner.class); + testKeyPair = createTestKeyPair(); + when(crossClusterApiKeySigner.loadSigningConfig(any(), any())).thenReturn( + Optional.of(new CrossClusterApiKeySigner.SigningConfig(testKeyPair, List.of())) + ); + Settings settings = Settings.builder().put("resource.reload.interval.high", TimeValue.timeValueMillis(100)).build(); + threadPool = new TestThreadPool(getTestName()); + resourceWatcherService = new ResourceWatcherService(settings, threadPool); + settingsBuilder = Settings.builder() + .put("path.home", createTempDir()) + .put(Node.NODE_NAME_SETTING.getKey(), randomAlphaOfLengthBetween(3, 8)); + } + + public void testSimpleDynamicSettingsUpdate() throws IOException { + Settings settings = settingsBuilder.put("cluster.remote.my_remote.signing.keystore.alias", "mykey").build(); + + var clusterSettings = new ClusterSettings(settings, new HashSet<>(CrossClusterApiKeySignerSettings.getDynamicSettings())); + + var crossClusterApiKeySigningConfigReloader = new CrossClusterApiKeySigningConfigReloader( + TestEnvironment.newEnvironment(settings), + resourceWatcherService, + clusterSettings + ); + crossClusterApiKeySigningConfigReloader.setApiKeySigner(crossClusterApiKeySigner); + clusterSettings.applySettings(Settings.builder().put("cluster.remote.my_remote.signing.keystore.alias", "anotherkey").build()); + verify(crossClusterApiKeySigner, times(1)).loadSigningConfig( + "my_remote", + Settings.builder() + .put("cluster.remote.my_remote.signing.keystore.alias", "anotherkey") + .build() + .getByPrefix("cluster.remote.my_remote.") + ); + } + + public void testDynamicSettingsUpdateWithAddedFiles() throws Exception { + var clusterNames = new String[] { "my_remote0", "my_remote1", "my_remote2" }; + var filesToMonitor = new Path[] { createTempFile(), createTempFile(), createTempFile() }; + var remoteClusterSettings = new Settings[filesToMonitor.length]; + var dynamicSettingsUpdate = Settings.builder() + .put("cluster.remote.my_remote0.signing.keystore.alias", "mykey") + .put("cluster.remote.my_remote0.signing.keystore.path", filesToMonitor[0]) + .put("cluster.remote.my_remote1.signing.keystore.alias", "mykey") + .put("cluster.remote.my_remote1.signing.keystore.path", filesToMonitor[1]) + .put("cluster.remote.my_remote2.signing.keystore.alias", "mykey") + .put("cluster.remote.my_remote2.signing.keystore.path", filesToMonitor[2]) + .build(); + + var clusterSettings = new ClusterSettings( + settingsBuilder.build(), + new HashSet<>(CrossClusterApiKeySignerSettings.getDynamicSettings()) + ); + + var crossClusterApiKeySigningConfigReloader = new CrossClusterApiKeySigningConfigReloader( + TestEnvironment.newEnvironment(settingsBuilder.build()), + resourceWatcherService, + clusterSettings + ); + var crossClusterApiKeySigner = mock(CrossClusterApiKeySigner.class); + + for (int i = 0; i < clusterNames.length; i++) { + remoteClusterSettings[i] = Settings.builder() + .put("cluster.remote." + clusterNames[i] + ".signing.keystore.alias", "mykey") + .put("cluster.remote." + clusterNames[i] + ".signing.keystore.path", filesToMonitor[i]) + .build(); + when( + crossClusterApiKeySigner.loadSigningConfig( + clusterNames[i], + remoteClusterSettings[i].getByPrefix("cluster.remote." + clusterNames[i] + ".") + ) + ).thenReturn(Optional.of(new CrossClusterApiKeySigner.SigningConfig(testKeyPair, Set.of(filesToMonitor[i])))); + when( + crossClusterApiKeySigner.loadSigningConfig( + clusterNames[i], + dynamicSettingsUpdate.getByPrefix("cluster.remote." + clusterNames[i] + ".") + ) + ).thenReturn(Optional.of(new CrossClusterApiKeySigner.SigningConfig(testKeyPair, Set.of(filesToMonitor[i])))); + } + + crossClusterApiKeySigningConfigReloader.setApiKeySigner(crossClusterApiKeySigner); + clusterSettings.applySettings(dynamicSettingsUpdate); + + for (int i = 0; i < clusterNames.length; i++) { + final String clusterName = "my_remote" + i; + var remoteClusterSetting = remoteClusterSettings[i].getByPrefix("cluster.remote." + clusterName + "."); + verify(crossClusterApiKeySigner, times(1)).loadSigningConfig(clusterName, remoteClusterSetting); + Files.writeString(filesToMonitor[i], "some content"); + assertBusy(() -> verify(crossClusterApiKeySigner, times(2)).loadSigningConfig(clusterName, remoteClusterSetting)); + } + } + + public void testSimpleSecureSettingsReload() { + var clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(CrossClusterApiKeySignerSettings.getDynamicSettings())); + var crossClusterApiKeySigningConfigReloader = new CrossClusterApiKeySigningConfigReloader( + TestEnvironment.newEnvironment(settingsBuilder.build()), + resourceWatcherService, + clusterSettings + ); + + crossClusterApiKeySigningConfigReloader.setApiKeySigner(crossClusterApiKeySigner); + + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString("cluster.remote.my_remote.signing.keystore.secure_password", "secret"); + Settings settings = Settings.builder().setSecureSettings(secureSettings).build(); + crossClusterApiKeySigningConfigReloader.reload(settings); + + verify(crossClusterApiKeySigner).loadSigningConfig("my_remote", settings.getByPrefix("cluster.remote.my_remote.")); + } + + public void testSecureSettingsReloadNoMatchingSecureSettings() { + var clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(CrossClusterApiKeySignerSettings.getDynamicSettings())); + var crossClusterApiKeySigningConfigReloader = new CrossClusterApiKeySigningConfigReloader( + TestEnvironment.newEnvironment(settingsBuilder.build()), + resourceWatcherService, + clusterSettings + ); + crossClusterApiKeySigningConfigReloader.setApiKeySigner(crossClusterApiKeySigner); + + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString("not.a.setting", "secret"); + Settings settings = Settings.builder().setSecureSettings(secureSettings).build(); + crossClusterApiKeySigningConfigReloader.reload(settings); + + verify(crossClusterApiKeySigner, times(0)).loadSigningConfig(any(), any()); + } + + public void testFileUpdatedReloaded() throws Exception { + var fileToMonitor = createTempFile(); + var clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(CrossClusterApiKeySignerSettings.getDynamicSettings())); + var initialSettings = settingsBuilder.put("cluster.remote.my_remote.signing.keystore.path", fileToMonitor).build(); + var crossClusterApiKeySigningConfigReloader = new CrossClusterApiKeySigningConfigReloader( + TestEnvironment.newEnvironment(initialSettings), + resourceWatcherService, + clusterSettings + ); + + crossClusterApiKeySigningConfigReloader.setApiKeySigner(crossClusterApiKeySigner); + + verify(crossClusterApiKeySigner, times(0)).loadSigningConfig(anyString(), any()); + Files.writeString(fileToMonitor, "some content"); + assertBusy( + () -> verify(crossClusterApiKeySigner, times(1)).loadSigningConfig( + "my_remote", + initialSettings.getByPrefix("cluster.remote.my_remote.") + ) + ); + } + + public void testFileDeletedReloaded() throws Exception { + var fileToMonitor = createTempFile(); + var clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(CrossClusterApiKeySignerSettings.getDynamicSettings())); + var initialSettings = settingsBuilder.put("cluster.remote.my_remote.signing.keystore.path", fileToMonitor).build(); + var crossClusterApiKeySigningConfigReloader = new CrossClusterApiKeySigningConfigReloader( + TestEnvironment.newEnvironment(initialSettings), + resourceWatcherService, + clusterSettings + ); + + crossClusterApiKeySigningConfigReloader.setApiKeySigner(crossClusterApiKeySigner); + + verify(crossClusterApiKeySigner, times(0)).loadSigningConfig(anyString(), any()); + Files.delete(fileToMonitor); + assertBusy( + () -> verify(crossClusterApiKeySigner, times(1)).loadSigningConfig( + "my_remote", + initialSettings.getByPrefix("cluster.remote.my_remote.") + ) + ); + } + + private CrossClusterApiKeySigner.X509KeyPair createTestKeyPair() throws CertificateEncodingException { + var certMock = mock(X509Certificate.class); + when(certMock.getEncoded()).thenReturn(new byte[0]); + var privateKeyMock = mock(PrivateKey.class); + when(privateKeyMock.getAlgorithm()).thenReturn(randomFrom("RSA", "EC")); + + return new CrossClusterApiKeySigner.X509KeyPair(certMock, privateKeyMock); + } + + @After + public void tearDownThreadPool() { + terminate(threadPool); + } + +} diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing.bcfks b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing.bcfks new file mode 100644 index 0000000000000..fe9c079bc9808 Binary files /dev/null and b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing.bcfks differ diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing.jks b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing.jks new file mode 100644 index 0000000000000..a15da99f27dd2 Binary files /dev/null and b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing.jks differ diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_ec.crt b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_ec.crt new file mode 100644 index 0000000000000..102eb8278ec71 --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_ec.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBjDCCATOgAwIBAgIULGYxJUdnvCzcwcsh4em8hSPD0lYwCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTAgFw0yNTA5MDQxMTUyMjhaGA8y +Mjk5MDYxOTExNTIyOFowGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABKpa9uHCqE4bs4pCqtlUt0mH2fR2uoqYpWhb +BQXW6VbqZEZIgWg1N8fw8EhREHkF++gdoRv4hhms8A5Ph1hVHfqjUzBRMB0GA1Ud +DgQWBBSiXYpB4EIFnACHMJTSsAeSvcE90DAfBgNVHSMEGDAWgBSiXYpB4EIFnACH +MJTSsAeSvcE90DAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIDMI +8OAE9qbykxNMxLIAhNPJCFjJGE/U4/smQpIYB8MAAiBNgT9Nqv2MpUS3E+9Tg6u2 +/VSjqC+6Yy7sh4cd7GlG0Q== +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_ec.key b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_ec.key new file mode 100644 index 0000000000000..b10ab58b5dd3f --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_ec.key @@ -0,0 +1,8 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBBZ9OGpiId2dhQXH9Wj +E6UsAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQUCYy3Ay5F7QODsco +vtaCxQSBkD6HuBgyX7GH9xW+RAxDr2hgOgasgwHbZiT/IT8xQDr5+tYY8Rstczm+ +d9ioa5MXHaMDhrQtUW+XQpwd56a0ygrdar5b5N/oA8/f/64hj/pTgAMxnIjwfyJg +0NBU/h3fPZ4zCS7HmPa6oqpnWhfnphKlURX9ANcvOvYOCzntWU3sfS2hL8vtBduA +NzizssOhjg== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_rsa.crt b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_rsa.crt new file mode 100644 index 0000000000000..702172f48d03a --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_rsa.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWzCCAkOgAwIBAgIUSgWZycmZNvUi8VhMjqAjSTaugckwDQYJKoZIhvcNAQEL +BQAwQDETMBEGCgmSJomT8ixkARkWA25ldDEXMBUGCgmSJomT8ixkARkWB2V4YW1w +bGUxEDAOBgNVBAMTB3NpZ25pbmcwHhcNMjUwNzAzMDcyNjE5WhcNMjgwNzAyMDcy +NjE5WjBAMRMwEQYKCZImiZPyLGQBGRYDbmV0MRcwFQYKCZImiZPyLGQBGRYHZXhh +bXBsZTEQMA4GA1UEAxMHc2lnbmluZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALD2UgeR5s6PIAdRSORhrHjfoNPunvNj9oMEANRYjqhWJ7ouyzFSB3GG +FtLbe4enrRHIUUCIQt60g6SvCAX+GCS3WemM19+70vc58rTh7JDbB4kMkXYeXT8H +Izm+TArgAV6scIvLwFNmgIuZp+YXWNFrV1eRv2OMSQ0c/aIu9WqS+GNem6TG1bXp +y1n+JtYNSyEJE0DOWUxFDfQz9/9HAGspiiyl/rBzTzYFwT1DuLKVjdLvXvhCHgaE +5pCWJ0HXFSMMHNWzdZdao7t7xpDf3ZMocTOAoC9o53YVMmOIkKgJDKIq4e9Ywsz8 +R+IiUWzEcfgac2wzWOlO+h7qC8kTCYMCAwEAAaNNMEswHQYDVR0OBBYEFNAC8Hgn +1NMUo/EmA17LvfphN8YtMB8GA1UdIwQYMBaAFNAC8Hgn1NMUo/EmA17LvfphN8Yt +MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggEBAFZ5yB9OV9YjOVrIyQiOtTru +xLl2qM/+UI/UIDbjMfK4grjhLfQBoSp/Qyd+YFoPPwJkcJpQSrj+JZ6l5DtU7put +PTklHmjl7uG1Pf7viMB0Cb14dFia+a7V+PEn8lNVrXTkBqjhX3j2RZ7dpWLF0OIh +tzcjwS4BR+axLAJbqKhhGg/zb+FeFkZggTY9FAp8IhgDRWOR9ky23zIA10U1ebds +SPsbCszxIfCEjp+KaHDSQJM5WRXnoLQQB+XdHq7WDu0fYdVvgjQXpGw4QFN0t/FU +wWicTQEYEWylcz3t/cdhnLw7HsPwYvXbGF2neuxVdmmiUDLFlnf64D6T86etTmE= +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_rsa.key b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_rsa.key new file mode 100644 index 0000000000000..36dfc424fddb0 --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/signature/signing_rsa.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsPZSB5Hmzo8gB1FI5GGseN+g0+6e82P2gwQA1FiOqFYnui7L +MVIHcYYW0tt7h6etEchRQIhC3rSDpK8IBf4YJLdZ6YzX37vS9znytOHskNsHiQyR +dh5dPwcjOb5MCuABXqxwi8vAU2aAi5mn5hdY0WtXV5G/Y4xJDRz9oi71apL4Y16b +pMbVtenLWf4m1g1LIQkTQM5ZTEUN9DP3/0cAaymKLKX+sHNPNgXBPUO4spWN0u9e ++EIeBoTmkJYnQdcVIwwc1bN1l1qju3vGkN/dkyhxM4CgL2jndhUyY4iQqAkMoirh +71jCzPxH4iJRbMRx+BpzbDNY6U76HuoLyRMJgwIDAQABAoIBAAXtU+jOqkWmvpDT +u8E4RA4V7EoHk2YdbRD+39R1zjOf9406P+QfudcY83mG12mGQLP/fnGkKWNuvO5A +TlJS1sfYCkoS4fdys4mZguIr+I4rf+JHSlH1iSzGiAlXW6BUgoAf4h1bqtxcsey7 +EYD1O1cOFw6m940j2Y+iYKrUzCVlxwlA23Ms589mMliRwR0FnKd+52tB4O4FfwCC +g/IU/zcFgqoMX7q3Qdmv0nQ0yb3ixEYcS34NurdmbhNWgx5yQMEzUQjHDHcZ3z5q +QkSCHavypOeCwOF1qoPIoKQ9Z3SEuW/Z/4e4/mWt/UICMDztqfjiYQouMbK3D4wn +i84lnC0CgYEAw5RflFButojyOs6N/0N+788WAAbc/XjYAYMXznuJY+jmixAJH5Z9 +mn6M+g/3y0OokunPEWeg5lQ1/zJMat/dwOSHVHJ2Cha4Jnkt8JgsJAr+pUzQvrm/ +SERLNKRNVNFgeUVnMz6hqV7pYVZ7zwvQhOT4l2yCPP7BgP+DykPe/o0CgYEA56GR +6HPFtND5ddoJ5DBbWe1v7tOnOknHANFEEqNcA0emgiqVf4dyb5i23WW3GYKxWk/W +66FEtv3LGZ9IoGDAWM+eZMPtCv8kC9OPpK3Nk0Icsybj+s6uc6xLVNYn7criYBiz +jnTWjGPI/jiFn91sYtrJd0XGrE2R8hB62FcbbE8CgYAftriE8UHyoWQ9+u51nPlB +Y6AaowJEq8rC/AHpPoj5xXNUy2XfVGTLn4e2qM4yjKcSI42rMdWaY79ZwUs47VIl +DCmRnPndCvATdQTpBZPqyEmgfkM/GhmVW1WilJ3hig4NvB5O5fIK59QKL57l5PGM +CyDwVO0NfPXduBEjxDutpQKBgQCkGo0D8hnNHAzQ2RQO7c+aq6SUwLEGk8SAqMIg +rkn/LOEj8UWPX4fM1pYfzvNlCHncMRpkQBItzyr4USgkL8e2ZAmk/EZRdyezlUR8 +eIJf5QPuTQxR4eIoo5WPWlZZm1a8nGOB9vcV6ZA5xBOvijFC7By1+uJhqmdO5ywR +X81W8wKBgDLZ8XEfDX6/He6I2368c5JaNamlij6/RMssC+Kn5c0+IWHURxg0rcTl +CpPw4ZS96VNWWaU4OgA6txfGbS0CEZmlzWgErD2/yMvd+z9b2n0XZcZWVGvBTA0E +Ltm64LSnAaEjahZigl1wdtuAFjQM28ZsgIPr2uBwyZFrPh9cIeU4 +-----END RSA PRIVATE KEY-----