diff --git a/geode-core/src/distributedTest/java/org/apache/geode/cache/ssl/HybridCASSLDUnitTest.java b/geode-core/src/distributedTest/java/org/apache/geode/cache/ssl/HybridCASSLDUnitTest.java new file mode 100644 index 000000000000..303e82987220 --- /dev/null +++ b/geode-core/src/distributedTest/java/org/apache/geode/cache/ssl/HybridCASSLDUnitTest.java @@ -0,0 +1,344 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.geode.cache.ssl; + +import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENABLED_COMPONENTS; +import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENDPOINT_IDENTIFICATION_ENABLED; +import static org.apache.geode.security.SecurableCommunicationChannels.CLUSTER; +import static org.apache.geode.security.SecurableCommunicationChannels.SERVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Properties; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.geode.cache.Region; +import org.apache.geode.cache.RegionShortcut; +import org.apache.geode.cache.client.ClientRegionShortcut; +import org.apache.geode.test.dunit.IgnoredException; +import org.apache.geode.test.dunit.rules.ClientVM; +import org.apache.geode.test.dunit.rules.ClusterStartupRule; +import org.apache.geode.test.dunit.rules.MemberVM; +import org.apache.geode.test.junit.categories.ClientServerTest; + +/** + * Tests hybrid TLS configuration where: + * - Servers use certificates issued by a public CA (e.g., Let's Encrypt, DigiCert) + * - Clients use certificates issued by a private/enterprise CA + * + * This configuration mitigates the impact of public CA changes that affect the + * Client Authentication Extended Key Usage (EKU). See the Apache Geode security + * documentation for details. + * + * Key requirements validated: + * - Server certificates must include serverAuth EKU and subjectAltName + * - Client certificates must include clientAuth EKU + * - Servers trust the private CA to validate client certificates + * - Clients trust the public CA to validate server certificates + */ +@Category({ClientServerTest.class}) +public class HybridCASSLDUnitTest { + + private HybridCATestFixture fixture; + + @Rule + public ClusterStartupRule cluster = new ClusterStartupRule(); + + @Before + public void setup() { + fixture = new HybridCATestFixture(); + fixture.setup(); + + // Ignore expected exceptions during locator/server shutdown with SSL + IgnoredException.addIgnoredException("Could not stop Locator"); + IgnoredException.addIgnoredException("ForcedDisconnectException"); + } + + /** + * Tests basic client-server connection with hybrid TLS. + * Verifies that a client with a private-CA certificate can connect to + * a server with a public-CA certificate when trust is properly configured. + * + * Note: This test uses SSL only for client-server communication (SERVER component), + * not for peer-to-peer cluster communication, which simplifies the test setup. + */ + @Test + public void testHybridTLSBasicConnection() throws Exception { + // Start locator without SSL (peer-to-peer communication doesn't require SSL for this test) + MemberVM locator = cluster.startLocatorVM(0); + + // Create server with public-CA certificate, SSL enabled only for SERVER component + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(SERVER, true, true); + serverProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + // Create region on server + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Create client with private-CA certificate + CertStores clientStore = fixture.createClientStores("1"); + Properties clientProps = clientStore.propertiesWith(SERVER, true, true); + clientProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + ClientVM client = cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + + // Verify client can perform operations + client.invoke(() -> { + Region clientRegion = ClusterStartupRule.getClientCache() + .createClientRegionFactory(ClientRegionShortcut.PROXY) + .create("testRegion"); + + clientRegion.put("key1", "value1"); + assertThat(clientRegion.get("key1")).isEqualTo("value1"); + }); + } + + /** + * Tests that client authentication is properly enforced in hybrid TLS. + * A client without a certificate should be rejected. + */ + @Test + public void testHybridTLSClientAuthenticationRequired() throws Exception { + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(SERVER, true, true); + serverProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + MemberVM locator = cluster.startLocatorVM(0); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Create client with only truststore (no keystore with client certificate) + CertStores clientStore = CertStores.clientStore(); + clientStore.trust("publicCA", fixture.getPublicCA()); + Properties clientProps = clientStore.propertiesWith(SERVER, true, true); + clientProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("java.io.IOException"); + IgnoredException.addIgnoredException("Broken pipe"); + + // Attempt to create client VM should fail + assertThatThrownBy(() -> { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + + IgnoredException.removeAllExpectedExceptions(); + } + + /** + * Tests endpoint identification (hostname verification) with hybrid TLS. + * Verifies that the server certificate's subjectAltName is validated. + */ + @Test + public void testHybridTLSWithEndpointIdentification() throws Exception { + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(SERVER, true, true); + serverProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + serverProps.setProperty(SSL_ENDPOINT_IDENTIFICATION_ENABLED, "true"); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + CertStores clientStore = fixture.createClientStores("1"); + Properties clientProps = clientStore.propertiesWith(SERVER, true, true); + clientProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + clientProps.setProperty(SSL_ENDPOINT_IDENTIFICATION_ENABLED, "true"); + + ClientVM client = cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + + client.invoke(() -> { + Region clientRegion = ClusterStartupRule.getClientCache() + .createClientRegionFactory(ClientRegionShortcut.PROXY) + .create("testRegion"); + + clientRegion.put("key1", "value1"); + assertThat(clientRegion.get("key1")).isEqualTo("value1"); + }); + } + + /** + * Tests that peer-to-peer SSL works with hybrid TLS. + * Multiple servers should be able to communicate using public-CA certificates. + */ + @Test + public void testHybridTLSPeerToPeerCommunication() throws Exception { + CertStores locatorStore = fixture.createLocatorStores("1"); + Properties locatorProps = locatorStore.propertiesWith(CLUSTER, false, true); + + MemberVM locator = cluster.startLocatorVM(0, locatorProps); + + // Start two servers + CertStores serverStore1 = fixture.createServerStores("1"); + Properties serverProps1 = serverStore1.propertiesWith(CLUSTER, true, true); + serverProps1.setProperty(SSL_ENABLED_COMPONENTS, CLUSTER); + MemberVM server1 = cluster.startServerVM(1, serverProps1, locator.getPort()); + + CertStores serverStore2 = fixture.createServerStores("2"); + Properties serverProps2 = serverStore2.propertiesWith(CLUSTER, true, true); + serverProps2.setProperty(SSL_ENABLED_COMPONENTS, CLUSTER); + MemberVM server2 = cluster.startServerVM(2, serverProps2, locator.getPort()); + + // Create replicated region on both servers + server1.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + server2.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Put data from server1 + server1.invoke(() -> { + Region region = ClusterStartupRule.getCache().getRegion("testRegion"); + region.put("key1", "value1"); + }); + + // Verify data replicated to server2 + server2.invoke(() -> { + Region region = ClusterStartupRule.getCache().getRegion("testRegion"); + assertThat(region.get("key1")).isEqualTo("value1"); + }); + } + + /** + * Tests that clients can connect when only SERVER component has SSL enabled. + * This validates component-specific SSL configuration. + */ + @Test + public void testHybridTLSServerComponentOnly() throws Exception { + // Server uses hybrid TLS only for SERVER component + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(SERVER, true, true); + // Locator doesn't need SSL + serverProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + MemberVM locator = cluster.startLocatorVM(0); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Client uses hybrid TLS for SERVER component + CertStores clientStore = fixture.createClientStores("1"); + Properties clientProps = clientStore.propertiesWith(SERVER, true, true); + clientProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + ClientVM client = cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + + client.invoke(() -> { + Region clientRegion = ClusterStartupRule.getClientCache() + .createClientRegionFactory(ClientRegionShortcut.PROXY) + .create("testRegion"); + + clientRegion.put("key1", "value1"); + assertThat(clientRegion.get("key1")).isEqualTo("value1"); + }); + } + + /** + * Tests multiple clients connecting with different private-CA certificates. + * Validates that each client can authenticate independently. + */ + @Test + public void testHybridTLSMultipleClients() throws Exception { + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(SERVER, true, true); + serverProps.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + MemberVM locator = cluster.startLocatorVM(0); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Create first client + CertStores clientStore1 = fixture.createClientStores("client1"); + Properties clientProps1 = clientStore1.propertiesWith(SERVER, true, true); + clientProps1.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + ClientVM client1 = cluster.startClientVM(2, clientProps1, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + + client1.invoke(() -> { + Region clientRegion1 = ClusterStartupRule.getClientCache() + .createClientRegionFactory(ClientRegionShortcut.PROXY) + .create("testRegion"); + + clientRegion1.put("client1-key", "client1-value"); + }); + + // Create second client with different certificate + CertStores clientStore2 = fixture.createClientStores("client2"); + Properties clientProps2 = clientStore2.propertiesWith(SERVER, true, true); + clientProps2.setProperty(SSL_ENABLED_COMPONENTS, SERVER); + + ClientVM client2 = cluster.startClientVM(3, clientProps2, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + + client2.invoke(() -> { + Region clientRegion2 = ClusterStartupRule.getClientCache() + .createClientRegionFactory(ClientRegionShortcut.PROXY) + .create("testRegion"); + + // Verify second client can see first client's data + assertThat(clientRegion2.get("client1-key")).isEqualTo("client1-value"); + + // Verify second client can put its own data + clientRegion2.put("client2-key", "client2-value"); + }); + + // Verify first client can see second client's data + client1.invoke(() -> { + Region clientRegion1 = + ClusterStartupRule.getClientCache().getRegion("testRegion"); + assertThat(clientRegion1.get("client2-key")).isEqualTo("client2-value"); + }); + } +} diff --git a/geode-core/src/distributedTest/java/org/apache/geode/cache/ssl/HybridCASSLNegativeTest.java b/geode-core/src/distributedTest/java/org/apache/geode/cache/ssl/HybridCASSLNegativeTest.java new file mode 100644 index 000000000000..30e7b8a4cc43 --- /dev/null +++ b/geode-core/src/distributedTest/java/org/apache/geode/cache/ssl/HybridCASSLNegativeTest.java @@ -0,0 +1,386 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.geode.cache.ssl; + +import static org.apache.geode.distributed.ConfigurationProperties.SSL_ENDPOINT_IDENTIFICATION_ENABLED; +import static org.apache.geode.security.SecurableCommunicationChannels.ALL; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Properties; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.geode.cache.RegionShortcut; +import org.apache.geode.test.dunit.IgnoredException; +import org.apache.geode.test.dunit.rules.ClusterStartupRule; +import org.apache.geode.test.dunit.rules.MemberVM; +import org.apache.geode.test.junit.categories.ClientServerTest; + +/** + * Negative tests for hybrid TLS configuration. + * Validates that improper configurations are properly rejected with appropriate errors. + * + * These tests verify the troubleshooting scenarios documented in the security guide: + * - Missing clientAuth EKU causes "certificate_unknown" alert + * - Wrong CA trust causes PKIX path validation failure + * - Missing subjectAltName causes hostname verification failure + */ +@Category({ClientServerTest.class}) +public class HybridCASSLNegativeTest { + + private HybridCATestFixture fixture; + + @Rule + public ClusterStartupRule cluster = new ClusterStartupRule(); + + @Before + public void setup() { + fixture = new HybridCATestFixture(); + fixture.setup(); + + // Ignore expected exceptions during locator/server shutdown with SSL + IgnoredException.addIgnoredException("Could not stop Locator"); + IgnoredException.addIgnoredException("ForcedDisconnectException"); + } + + /** + * Tests that a server configured to trust the wrong CA rejects client connections. + * Expected error: PKIX path validation failed + */ + @Test + public void testServerTrustsWrongCAForClient() throws Exception { + // Create a different CA that server will trust (but client cert is not issued by it) + CertificateMaterial wrongCA = new CertificateBuilder() + .commonName("Wrong CA") + .isCA() + .generate(); + + // Server certificate from public CA + CertificateMaterial serverCert = fixture.createServerCertificate("server-1"); + + // Server trusts wrong CA (not the private CA that issued client cert) + CertStores serverStore = CertStores.serverStore(); + serverStore.withCertificate("server", serverCert); + serverStore.trust("wrongCA", wrongCA); // Should trust privateCA instead + + Properties serverProps = serverStore.propertiesWith(ALL, true, true); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Client with private-CA certificate + CertStores clientStore = fixture.createClientStores("1"); + Properties clientProps = clientStore.propertiesWith(ALL, true, true); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("sun.security.validator.ValidatorException"); + IgnoredException.addIgnoredException("PKIX path"); + IgnoredException.addIgnoredException("java.io.IOException"); + IgnoredException.addIgnoredException("Broken pipe"); + + // Client connection should fail with PKIX path validation error + assertThatThrownBy(() -> { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + + IgnoredException.removeAllExpectedExceptions(); + } + + /** + * Tests that a client configured to trust the wrong CA rejects server connections. + * Expected error: PKIX path validation failed + */ + @Test + public void testClientTrustsWrongCAForServer() throws Exception { + // Create a different CA that client will trust (but server cert is not issued by it) + CertificateMaterial wrongCA = new CertificateBuilder() + .commonName("Wrong CA") + .isCA() + .generate(); + + // Server with correct configuration + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(ALL, true, true); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Client trusts wrong CA (not the public CA that issued server cert) + CertificateMaterial clientCert = fixture.createClientCertificate("client-1"); + CertStores clientStore = CertStores.clientStore(); + clientStore.withCertificate("client", clientCert); + clientStore.trust("wrongCA", wrongCA); // Should trust publicCA instead + + Properties clientProps = clientStore.propertiesWith(ALL, true, true); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("sun.security.validator.ValidatorException"); + IgnoredException.addIgnoredException("PKIX path"); + IgnoredException.addIgnoredException("java.security.cert.CertificateException"); + + // Client connection should fail with PKIX path validation error + assertThatThrownBy(() -> { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + + IgnoredException.removeAllExpectedExceptions(); + } + + /** + * Tests that a client certificate without clientAuth EKU is rejected. + * Expected error: certificate_unknown (as documented in troubleshooting guide) + * + * This validates the critical requirement that client certificates must have + * the clientAuth Extended Key Usage. + */ + @Test + public void testClientCertificateMissingClientAuthEKU() throws Exception { + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(ALL, true, true); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Client certificate WITHOUT clientAuth EKU + CertificateMaterial clientCert = + fixture.createClientCertificateWithoutClientAuthEKU("client-1"); + CertStores clientStore = CertStores.clientStore(); + clientStore.withCertificate("client", clientCert); + clientStore.trust("publicCA", fixture.getPublicCA()); + + Properties clientProps = clientStore.propertiesWith(ALL, true, true); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("certificate_unknown"); + IgnoredException.addIgnoredException("java.io.IOException"); + IgnoredException.addIgnoredException("Broken pipe"); + IgnoredException.addIgnoredException("Connection reset"); + + // Connection should fail with certificate_unknown or handshake failure + assertThatThrownBy(() -> { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + + IgnoredException.removeAllExpectedExceptions(); + } + + /** + * Tests that a server certificate without subjectAltName fails hostname verification. + * Expected error: No subject alternative names present (when endpoint identification enabled) + * + * This validates the requirement that server certificates must include SAN for hostname + * verification. + */ + @Test + public void testServerCertificateMissingSAN() throws Exception { + // Server certificate without SAN + CertificateMaterial serverCert = fixture.createServerCertificateWithoutSAN("server-1"); + + CertStores serverStore = CertStores.serverStore(); + serverStore.withCertificate("server", serverCert); + serverStore.trust("privateCA", fixture.getPrivateCA()); + + Properties serverProps = serverStore.propertiesWith(ALL, true, true); + serverProps.setProperty(SSL_ENDPOINT_IDENTIFICATION_ENABLED, "true"); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + CertStores clientStore = fixture.createClientStores("1"); + Properties clientProps = clientStore.propertiesWith(ALL, true, true); + clientProps.setProperty(SSL_ENDPOINT_IDENTIFICATION_ENABLED, "true"); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("java.security.cert.CertificateException"); + IgnoredException.addIgnoredException("No subject alternative"); + IgnoredException.addIgnoredException("No name matching"); + + // Connection should fail with hostname verification error + assertThatThrownBy(() -> { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + + IgnoredException.removeAllExpectedExceptions(); + } + + /** + * Tests that mutual authentication is enforced when ssl-require-authentication=true. + * A server without a client certificate in its keystore should not be able to join as a client. + */ + @Test + public void testMutualAuthenticationEnforced() throws Exception { + CertStores serverStore = fixture.createServerStores("1"); + Properties serverProps = serverStore.propertiesWith(ALL, true, true); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + // Create a "client" with no certificate in keystore, only truststore + CertStores invalidClientStore = CertStores.clientStore(); + // Only trust, no certificate + invalidClientStore.trust("publicCA", fixture.getPublicCA()); + + Properties clientProps = invalidClientStore.propertiesWith(ALL, true, true); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("bad_certificate"); + IgnoredException.addIgnoredException("java.io.IOException"); + IgnoredException.addIgnoredException("Broken pipe"); + + // Connection should fail - server requires client authentication + assertThatThrownBy(() -> { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + + IgnoredException.removeAllExpectedExceptions(); + } + + /** + * Tests that a server certificate without serverAuth EKU might be rejected + * (behavior depends on TLS implementation, but good practice to include it). + */ + @Test + public void testServerCertificateWithoutServerAuthEKU() throws Exception { + // Create server cert without serverAuth EKU + CertificateMaterial serverCert = new CertificateBuilder() + .commonName("server-1") + .issuedBy(fixture.getPublicCA()) + .sanDnsName("localhost") + // Intentionally omit serverAuthEKU() + .generate(); + + CertStores serverStore = CertStores.serverStore(); + serverStore.withCertificate("server", serverCert); + serverStore.trust("privateCA", fixture.getPrivateCA()); + + Properties serverProps = serverStore.propertiesWith(ALL, true, true); + + MemberVM locator = cluster.startLocatorVM(0, serverProps); + MemberVM server = cluster.startServerVM(1, serverProps, locator.getPort()); + + server.invoke(() -> { + ClusterStartupRule.getCache() + .createRegionFactory(RegionShortcut.REPLICATE) + .create("testRegion"); + }); + + CertStores clientStore = fixture.createClientStores("1"); + Properties clientProps = clientStore.propertiesWith(ALL, true, true); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("extended key usage"); + + // Some TLS implementations may reject this, others may accept + // The test documents that including serverAuth EKU is best practice + try { + cluster.startClientVM(2, clientProps, + ccf -> ccf.addPoolLocator("localhost", locator.getPort())); + // If it succeeds, that's OK - not all implementations strictly enforce + } catch (Exception e) { + // If it fails, verify it's SSL-related + assertThatThrownBy(() -> { + throw e; + }).hasCauseInstanceOf(javax.net.ssl.SSLHandshakeException.class); + } finally { + IgnoredException.removeAllExpectedExceptions(); + } + } + + /** + * Tests mixed configuration where one server has correct hybrid TLS and another doesn't. + * The misconfigured server should fail to join the cluster. + */ + @Test + public void testMisconfiguredServerCannotJoinCluster() throws Exception { + CertStores locatorStore = fixture.createLocatorStores("1"); + Properties locatorProps = locatorStore.propertiesWith(ALL, true, true); + + MemberVM locator = cluster.startLocatorVM(0, locatorProps); + + // First server with correct configuration + CertStores serverStore1 = fixture.createServerStores("1"); + Properties serverProps1 = serverStore1.propertiesWith(ALL, true, true); + MemberVM server1 = cluster.startServerVM(1, serverProps1, locator.getPort()); + + // Second server with wrong CA certificates + CertificateMaterial wrongCA = new CertificateBuilder() + .commonName("Wrong CA") + .isCA() + .generate(); + + CertificateMaterial wrongServerCert = new CertificateBuilder() + .commonName("server-2") + .issuedBy(wrongCA) + .serverAuthEKU() + .sanDnsName("localhost") + .generate(); + + CertStores serverStore2 = CertStores.serverStore(); + serverStore2.withCertificate("server", wrongServerCert); + serverStore2.trust("wrongCA", wrongCA); + + Properties serverProps2 = serverStore2.propertiesWith(ALL, true, true); + + IgnoredException.addIgnoredException("javax.net.ssl.SSLHandshakeException"); + IgnoredException.addIgnoredException("PKIX path"); + IgnoredException.addIgnoredException("ForcedDisconnectException"); + IgnoredException.addIgnoredException("java.io.IOException"); + + // Second server should fail to join cluster + assertThatThrownBy(() -> { + cluster.startServerVM(2, serverProps2, locator.getPort()); + }).hasCauseInstanceOf(java.io.IOException.class); + + IgnoredException.removeAllExpectedExceptions(); + } +} diff --git a/geode-junit/build.gradle b/geode-junit/build.gradle index dd1e51de3614..f2c19d86b668 100755 --- a/geode-junit/build.gradle +++ b/geode-junit/build.gradle @@ -22,14 +22,17 @@ plugins { compileJava { // -Xlint:-sunapi flag removed as it doesn't exist in Java 17 - // Added --add-exports for sun.security.x509 package access needed for CertificateBuilder + // Added --add-exports for sun.security packages needed for CertificateBuilder options.compilerArgs << '-XDenableSunApiLintControl' options.compilerArgs << '--add-exports=java.base/sun.security.x509=ALL-UNNAMED' + options.compilerArgs << '--add-exports=java.base/sun.security.util=ALL-UNNAMED' } javadoc { - // Add --add-exports for sun.security.x509 package access needed for CertificateBuilder javadoc generation - options.addStringOption('-add-exports', 'java.base/sun.security.x509=ALL-UNNAMED') + // Exclude classes that use internal sun.security packages to avoid javadoc errors + options.addBooleanOption('Xdoclint:none', true) + exclude '**/CertificateBuilder.java' + exclude '**/HybridCATestFixture.java' } dependencies { diff --git a/geode-junit/src/main/java/org/apache/geode/cache/ssl/CertificateBuilder.java b/geode-junit/src/main/java/org/apache/geode/cache/ssl/CertificateBuilder.java index 45995a19b8e4..66cec0267032 100644 --- a/geode-junit/src/main/java/org/apache/geode/cache/ssl/CertificateBuilder.java +++ b/geode-junit/src/main/java/org/apache/geode/cache/ssl/CertificateBuilder.java @@ -30,6 +30,7 @@ import java.util.Date; import java.util.List; +import sun.security.util.ObjectIdentifier; import sun.security.x509.AlgorithmId; import sun.security.x509.BasicConstraintsExtension; import sun.security.x509.CertificateAlgorithmId; @@ -39,6 +40,7 @@ import sun.security.x509.CertificateVersion; import sun.security.x509.CertificateX509Key; import sun.security.x509.DNSName; +import sun.security.x509.ExtendedKeyUsageExtension; import sun.security.x509.GeneralName; import sun.security.x509.GeneralNames; import sun.security.x509.IPAddressName; @@ -64,6 +66,7 @@ public class CertificateBuilder { private final List ipAddresses; private boolean isCA; private CertificateMaterial issuer; + private final List extendedKeyUsages; public CertificateBuilder() { this(30, "SHA256withRSA"); @@ -74,6 +77,7 @@ public CertificateBuilder(int days, String algorithm) { this.algorithm = algorithm; dnsNames = new ArrayList<>(); ipAddresses = new ArrayList<>(); + extendedKeyUsages = new ArrayList<>(); } private static GeneralName dnsGeneralName(String name) { @@ -130,6 +134,38 @@ public CertificateBuilder issuedBy(CertificateMaterial issuer) { return this; } + /** + * Add Extended Key Usage purposes to the certificate. + * Common purposes: + * - "1.3.6.1.5.5.7.3.1" = serverAuth (TLS Web Server Authentication) + * - "1.3.6.1.5.5.7.3.2" = clientAuth (TLS Web Client Authentication) + * - "1.3.6.1.5.5.7.3.3" = codeSigning + */ + public CertificateBuilder extendedKeyUsage(String... oids) { + try { + for (String oid : oids) { + extendedKeyUsages.add(ObjectIdentifier.of(oid)); + } + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + return this; + } + + /** + * Add TLS Web Client Authentication Extended Key Usage (for client certificates). + */ + public CertificateBuilder clientAuthEKU() { + return extendedKeyUsage("1.3.6.1.5.5.7.3.2"); + } + + /** + * Add TLS Web Server Authentication Extended Key Usage (for server certificates). + */ + public CertificateBuilder serverAuthEKU() { + return extendedKeyUsage("1.3.6.1.5.5.7.3.1"); + } + private GeneralNames san() throws IOException { GeneralNames names = new GeneralNames(); for (String name : dnsNames) { @@ -210,6 +246,12 @@ private X509Certificate generate(PublicKey publicKey, PrivateKey privateKey) { extensions.set(BasicConstraintsExtension.NAME, basicConstraints); } + if (!extendedKeyUsages.isEmpty()) { + ExtendedKeyUsageExtension ekuExtension = + new ExtendedKeyUsageExtension(new java.util.Vector<>(extendedKeyUsages)); + extensions.set(ExtendedKeyUsageExtension.NAME, ekuExtension); + } + if (!extensions.getAllExtensions().isEmpty()) { info.set(X509CertInfo.EXTENSIONS, extensions); } diff --git a/geode-junit/src/main/java/org/apache/geode/cache/ssl/HybridCATestFixture.java b/geode-junit/src/main/java/org/apache/geode/cache/ssl/HybridCATestFixture.java new file mode 100644 index 000000000000..f5e5c41e7c86 --- /dev/null +++ b/geode-junit/src/main/java/org/apache/geode/cache/ssl/HybridCATestFixture.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.geode.cache.ssl; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Test fixture for creating hybrid TLS certificate configurations where servers use + * public-CA-issued certificates and clients use private-CA-issued certificates. + */ +public class HybridCATestFixture { + private CertificateMaterial publicCA; + private CertificateMaterial privateCA; + + /** + * Initialize the fixture by creating both public and private CAs. + */ + public void setup() { + // Create public CA (simulates certificates from Let's Encrypt, DigiCert, etc.) + publicCA = new CertificateBuilder() + .commonName("Public CA Root") + .isCA() + .generate(); + + // Create private/enterprise CA (for client certificates) + privateCA = new CertificateBuilder() + .commonName("Enterprise Internal CA") + .isCA() + .generate(); + } + + /** + * Get the public CA certificate material. + */ + public CertificateMaterial getPublicCA() { + return publicCA; + } + + /** + * Get the private CA certificate material. + */ + public CertificateMaterial getPrivateCA() { + return privateCA; + } + + /** + * Create a server certificate issued by the public CA. + * The certificate includes: + * - serverAuth Extended Key Usage + * - subjectAltName with DNS names and IP addresses for hostname verification + * + * @param hostname the server's hostname/common name + * @return certificate material for the server + */ + public CertificateMaterial createServerCertificate(String hostname) { + try { + return new CertificateBuilder() + .commonName(hostname) + .issuedBy(publicCA) + .serverAuthEKU() + .sanDnsName(hostname) + .sanDnsName("localhost") + .sanDnsName(InetAddress.getLocalHost().getHostName()) + .sanDnsName(InetAddress.getLocalHost().getCanonicalHostName()) + .sanIpAddress(InetAddress.getLocalHost()) + .sanIpAddress(InetAddress.getLoopbackAddress()) + .sanIpAddress("0.0.0.0") // for Windows compatibility + .generate(); + } catch (UnknownHostException e) { + throw new RuntimeException("Unable to determine localhost information", e); + } + } + + /** + * Create a server certificate with minimal SAN (for negative testing). + * + * @param hostname the server's hostname/common name + * @return certificate material for the server + */ + public CertificateMaterial createServerCertificateMinimalSAN(String hostname) { + return new CertificateBuilder() + .commonName(hostname) + .issuedBy(publicCA) + .serverAuthEKU() + .sanDnsName(hostname) + .generate(); + } + + /** + * Create a server certificate without any SAN (for negative testing). + * + * @param hostname the server's hostname/common name + * @return certificate material for the server + */ + public CertificateMaterial createServerCertificateWithoutSAN(String hostname) { + return new CertificateBuilder() + .commonName(hostname) + .issuedBy(publicCA) + .serverAuthEKU() + .generate(); + } + + /** + * Create a client certificate issued by the private CA. + * The certificate includes clientAuth Extended Key Usage which is required for mTLS. + * + * @param clientName the client's common name (e.g., "client1@example.com") + * @return certificate material for the client + */ + public CertificateMaterial createClientCertificate(String clientName) { + return new CertificateBuilder() + .commonName(clientName) + .issuedBy(privateCA) + .clientAuthEKU() + .generate(); + } + + /** + * Create a client certificate WITHOUT clientAuth EKU (for negative testing). + * This certificate will be rejected by servers requiring client authentication. + * + * @param clientName the client's common name + * @return certificate material for the client (invalid for mTLS) + */ + public CertificateMaterial createClientCertificateWithoutClientAuthEKU(String clientName) { + return new CertificateBuilder() + .commonName(clientName) + .issuedBy(privateCA) + // Intentionally omit .clientAuthEKU() + .generate(); + } + + /** + * Create server CertStores configured for hybrid TLS. + * - Keystore contains server certificate issued by public CA + * - Truststore contains BOTH CAs: + * * Public CA: to validate other servers/locators (peer-to-peer) + * * Private CA: to validate client certificates + * + * @param serverId identifier for this server's certificates + * @return configured CertStores for server + */ + public CertStores createServerStores(String serverId) { + CertificateMaterial serverCert = createServerCertificate("server-" + serverId); + + CertStores serverStore = CertStores.serverStore(); + serverStore.withCertificate("server", serverCert); + serverStore.trust("publicCA", publicCA); // Trust public CA for peer-to-peer + serverStore.trust("privateCA", privateCA); // Trust private CA to validate clients + + return serverStore; + } + + /** + * Create client CertStores configured for hybrid TLS. + * - Keystore contains client certificate issued by private CA + * - Truststore contains public CA to validate server certificates + * + * @param clientId identifier for this client's certificates + * @return configured CertStores for client + */ + public CertStores createClientStores(String clientId) { + CertificateMaterial clientCert = createClientCertificate("client-" + clientId); + + CertStores clientStore = CertStores.clientStore(); + clientStore.withCertificate("client", clientCert); + clientStore.trust("publicCA", publicCA); // Trust public CA to validate servers + + return clientStore; + } + + /** + * Create locator CertStores configured for hybrid TLS. + * Locators use the same configuration as servers: + * - Keystore contains locator certificate issued by public CA + * - Truststore contains BOTH CAs: + * * Public CA: to validate other locators/servers (peer-to-peer) + * * Private CA: to validate client certificates + * + * @param locatorId identifier for this locator's certificates + * @return configured CertStores for locator + */ + public CertStores createLocatorStores(String locatorId) { + CertificateMaterial locatorCert = createServerCertificate("locator-" + locatorId); + + CertStores locatorStore = CertStores.locatorStore(); + locatorStore.withCertificate("locator", locatorCert); + locatorStore.trust("publicCA", publicCA); // Trust public CA for peer-to-peer + locatorStore.trust("privateCA", privateCA); // Trust private CA to validate clients + + return locatorStore; + } +}