diff --git a/examples/src/main/java/com/marklogic/client/example/cookbook/SSLClientCreator.java b/examples/src/main/java/com/marklogic/client/example/cookbook/SSLClientCreator.java index 170c3bf1e..c42c6e75b 100644 --- a/examples/src/main/java/com/marklogic/client/example/cookbook/SSLClientCreator.java +++ b/examples/src/main/java/com/marklogic/client/example/cookbook/SSLClientCreator.java @@ -1,107 +1,86 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.example.cookbook; import java.io.IOException; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; import com.marklogic.client.DatabaseClient; import com.marklogic.client.DatabaseClientFactory; -import com.marklogic.client.DatabaseClientFactory.DigestAuthContext; -import com.marklogic.client.DatabaseClientFactory.SSLHostnameVerifier; import com.marklogic.client.document.TextDocumentManager; import com.marklogic.client.example.cookbook.Util.ExampleProperties; import com.marklogic.client.io.StringHandle; /** - * SSLClientCreator illustrates the basic approach for creating a client using SSL for database access. + * SSLClientCreator illustrates the basic approach for creating a client using + * SSL for database access. + * + *

+ * A JKS or PKCS12 truststore containing the server's CA certificate must be + * configured via + * {@code example.truststore.path} and {@code example.truststore.password} in + * {@code Example.properties} + * (or the equivalent system properties) before running this example. + *

* - * Note: to run this example, you must modify the REST server by specifying a SSL certificate template. + *

+ * Note: to run this example, you must also modify the REST server by specifying + * an SSL certificate template. + *

*/ public class SSLClientCreator { - public static void main(String[] args) throws IOException, KeyManagementException, NoSuchAlgorithmException { + public static void main(String[] args) throws IOException { run(Util.loadProperties()); } - public static void run(ExampleProperties props) throws NoSuchAlgorithmException, KeyManagementException { - System.out.println("example: "+SSLClientCreator.class.getName()); - - // create a trust manager - // (note: a real application should verify certificates. This - // naive trust manager which accepts all the certificates should be replaced - // by a valid trust manager or get a system default trust manager - // which would validate whether the remote authentication credentials - // should be trusted or not.) - TrustManager naiveTrustMgr[] = new X509TrustManager[] { - new X509TrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) { - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) { - } - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } - }; + public static void run(ExampleProperties props) { + System.out.println("example: " + SSLClientCreator.class.getName()); - // create an SSL context - SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); - /* - * Here, we use a naive TrustManager which would accept any certificate - * which the server produces. But in a real application, there should be a - * TrustManager which is initialized with a Keystore which would determine - * whether the remote authentication credentials should be trusted or not. - * - * If we init the sslContext with null TrustManager, it would use the - * /lib/security/cacerts file for trusted root certificates, if - * javax.net.ssl.trustStore system property is not set and - * /lib/security/jssecacerts is not present. See this link for - * more information on TrustManagers - - * http://docs.oracle.com/javase/7/docs/technotes/guides/security/jsse/ - * JSSERefGuide.html - * - * If self signed certificates, signed by CAs created internally are used, - * then the internal CA's root certificate should be added to the keystore. - * See this link - - * https://docs.oracle.com/cd/E19226-01/821-0027/geygn/index.html for adding - * a root certificate in the keystore. - */ - sslContext.init(null, naiveTrustMgr, null); + // Configure example.truststore.path and example.truststore.password in + // Example.properties. + if (props.trustStorePath == null || props.trustStorePath.isEmpty()) { + throw new IllegalStateException( + "example.truststore.path is not configured. Set it in Example.properties to the path of a JKS or " + + "PKCS12 truststore containing the server's CA certificate."); + } + if (props.trustStorePassword == null) { + throw new IllegalStateException( + "example.truststore.password is not configured. Set it in Example.properties."); + } - // create the client - // (note: a real application should use a COMMON, STRICT, or implemented hostname verifier) - DatabaseClient client = DatabaseClientFactory.newClient( - props.host, props.port, - new DigestAuthContext(props.writerUser, props.writerPassword) - .withSSLContext(sslContext, (X509TrustManager) naiveTrustMgr[0]) - .withSSLHostnameVerifier(SSLHostnameVerifier.ANY)); + // Create the client using the property-source API. SSL is configured + // declaratively via the + // truststore path and password so that the client validates the server + // certificate against + // the trusted CAs in that store. STRICT hostname verification ensures the + // server certificate + // CN/SANs are checked against the connected host. + try (DatabaseClient client = DatabaseClientFactory.newClient(propertyName -> switch (propertyName) { + case "marklogic.client.host" -> props.host; + case "marklogic.client.port" -> props.port; + case "marklogic.client.authType" -> "digest"; + case "marklogic.client.username" -> props.writerUser; + case "marklogic.client.password" -> props.writerPassword; + case "marklogic.client.sslProtocol" -> "TLSv1.3"; + case "marklogic.client.ssl.truststore.path" -> props.trustStorePath; + case "marklogic.client.ssl.truststore.password" -> props.trustStorePassword; + case "marklogic.client.sslHostnameVerifier" -> DatabaseClientFactory.SSLHostnameVerifier.STRICT; + default -> null; + })) { - // make use of the client connection - TextDocumentManager docMgr = client.newTextDocumentManager(); - String docId = "/example/text.txt"; - StringHandle handle = new StringHandle(); - handle.set("A simple text document"); - docMgr.write(docId, handle); + // make use of the client connection + TextDocumentManager docMgr = client.newTextDocumentManager(); + String docId = "/example/text.txt"; + StringHandle handle = new StringHandle(); + handle.set("A simple text document"); + docMgr.write(docId, handle); - System.out.println( - "Connected by SSL to "+props.host+":"+props.port+" as "+props.writerUser); + System.out.println( + "Connected by SSL to " + props.host + ":" + props.port + " as " + props.writerUser); - // clean up the written document - docMgr.delete(docId); + // clean up the written document + docMgr.delete(docId); - // release the client - client.release(); + } } } diff --git a/examples/src/main/java/com/marklogic/client/example/cookbook/Util.java b/examples/src/main/java/com/marklogic/client/example/cookbook/Util.java index 11abb4294..015bdf91b 100644 --- a/examples/src/main/java/com/marklogic/client/example/cookbook/Util.java +++ b/examples/src/main/java/com/marklogic/client/example/cookbook/Util.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.example.cookbook; @@ -29,6 +29,8 @@ static public class ExampleProperties { public String jdbcUrl; public String jdbcUser; public String jdbcPassword; + public String trustStorePath; + public String trustStorePassword; public ExampleProperties(Properties props) { super(); host = System.getProperty("EXAMPLE_HOST", props.getProperty("example.host")); @@ -43,6 +45,8 @@ public ExampleProperties(Properties props) { jdbcUrl = props.getProperty("example.jdbc.url"); jdbcUser = props.getProperty("example.jdbc.user"); jdbcPassword = props.getProperty("example.jdbc.password"); + trustStorePath = props.getProperty("example.truststore.path"); + trustStorePassword = props.getProperty("example.truststore.password"); } } diff --git a/examples/src/main/resources/Example.properties b/examples/src/main/resources/Example.properties index 3628fa400..017251f6a 100644 --- a/examples/src/main/resources/Example.properties +++ b/examples/src/main/resources/Example.properties @@ -11,3 +11,9 @@ example.port=8000 example.jdbc.url=jdbc:hsqldb:hsql://localhost:9002/employees example.jdbc.user=sa example.jdbc.password= + +# Path to a JKS or PKCS12 truststore containing the server's CA certificate. +# Required by SSLClientCreator when the server uses a certificate that is not +# present in the JVM default trust store (e.g. a self-signed or internal CA cert). +# example.truststore.path=/path/to/truststore.jks +# example.truststore.password=changeit diff --git a/marklogic-client-api-functionaltests/src/test/java/com/marklogic/client/functionaltest/ConnectedRESTQA.java b/marklogic-client-api-functionaltests/src/test/java/com/marklogic/client/functionaltest/ConnectedRESTQA.java index 1020c7bd7..7d9084342 100644 --- a/marklogic-client-api-functionaltests/src/test/java/com/marklogic/client/functionaltest/ConnectedRESTQA.java +++ b/marklogic-client-api-functionaltests/src/test/java/com/marklogic/client/functionaltest/ConnectedRESTQA.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.functionaltest; @@ -40,7 +40,6 @@ import java.io.InputStream; import java.security.*; import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; import java.util.*; import static org.junit.jupiter.api.Assertions.fail; @@ -71,6 +70,8 @@ public abstract class ConnectedRESTQA { private static String admin_password = null; private static String ml_certificate_password = null; private static String ml_certificate_file = null; + private static String ml_truststore_file = null; + private static String ml_truststore_password = null; private static String mlDataConfigDirPath = null; private static Boolean isLBHost = false; @@ -782,23 +783,6 @@ public static void setPathRangeIndexInDatabase(String dbName, JsonNode jnode) { */ public static SSLContext getSslContext() throws IOException, NoSuchAlgorithmException, KeyManagementException, KeyStoreException, CertificateException, UnrecoverableKeyException { - // create a trust manager - // (note: a real application should verify certificates) - - TrustManager tm = new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] x509Certificates, String s) { - // nothing to do - } - - public void checkServerTrusted(X509Certificate[] x509Certificates, String s) { - // nothing to do - } - - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; - // get the client certificate. In case we need to modify path. String mlCertFile = new String(ml_certificate_file); @@ -816,9 +800,32 @@ public X509Certificate[] getAcceptedIssuers() { keyManagerFactory.init(keyStore, ml_certificate_password.toCharArray()); KeyManager[] keyMgr = keyManagerFactory.getKeyManagers(); + // Build a trust manager that validates the server's certificate. + // When ml_truststore_file is configured, a TrustManagerFactory is initialised + // from that truststore (which must contain the MarkLogic server's CA certificate). + // Otherwise the JVM's default trust managers are used as a safe fallback. + final TrustManager[] trustManagers; + if (ml_truststore_file != null && !ml_truststore_file.trim().isEmpty()) { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + char[] tsPassword = ml_truststore_password != null ? ml_truststore_password.toCharArray() : new char[0]; + InputStream tsInput = property.getClass().getResourceAsStream(ml_truststore_file); + if (tsInput == null) { + throw new IOException("Truststore resource not found on classpath: " + ml_truststore_file + + ". Ensure the file exists under the test resources directory."); + } + try { + trustStore.load(tsInput, tsPassword); + } finally { + tsInput.close(); + } + trustManagers = SSLUtil.getTrustManagers(TrustManagerFactory.getDefaultAlgorithm(), trustStore); + } else { + trustManagers = SSLUtil.getDefaultTrustManagers(); + } + // create an SSL context SSLContext mlsslContext = SSLContext.getInstance(SSLUtil.DEFAULT_PROTOCOL); - mlsslContext.init(keyMgr, new TrustManager[] { tm }, null); + mlsslContext.init(keyMgr, trustManagers, null); return mlsslContext; } @@ -991,6 +998,8 @@ public static void loadGradleProperties() { ssl_enabled = properties.getProperty("restSSLset"); ml_certificate_password = properties.getProperty("ml_certificate_password"); ml_certificate_file = properties.getProperty("ml_certificate_file"); + ml_truststore_file = properties.getProperty("ml_truststore_file"); + ml_truststore_password = properties.getProperty("ml_truststore_password"); mlDataConfigDirPath = properties.getProperty("mlDataConfigDirPath"); isLBHost = "gateway".equalsIgnoreCase(properties.getProperty("marklogic.client.connectionType")); PROPERTY_WAIT = Integer.parseInt(isLBHost ? "15000" : "0"); diff --git a/marklogic-client-api-functionaltests/src/test/resources/test.properties b/marklogic-client-api-functionaltests/src/test/resources/test.properties index 29d3a7c76..8d0476e59 100644 --- a/marklogic-client-api-functionaltests/src/test/resources/test.properties +++ b/marklogic-client-api-functionaltests/src/test/resources/test.properties @@ -24,4 +24,10 @@ httpsPort=8013 ml_certificate_password=welcome ml_certificate_file=user.p12 +# Path to a JKS truststore (as a classpath resource) containing the MarkLogic +# server's CA certificate. Required when restSSLset=true; leave commented out +# for non-SSL test runs. +# ml_truststore_file=/truststore.jks +# ml_truststore_password= + mlDataConfigDirPath=src/test/java/com/marklogic/client/functionaltest diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/OneWaySSLTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/OneWaySSLTest.java index 4118b5d7a..29059d2c8 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/OneWaySSLTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/OneWaySSLTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.test.ssl; @@ -24,7 +24,11 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -73,11 +77,11 @@ void trustAllManager() throws Exception { SSLContext sslContext = SSLContext.getInstance(SSLUtil.DEFAULT_PROTOCOL); sslContext.init(null, new TrustManager[]{Common.TRUST_ALL_MANAGER}, null); - DatabaseClient client = Common.newClientBuilder() - .withSSLContext(sslContext) - .withTrustManager(Common.TRUST_ALL_MANAGER) - .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) - .build(); + DatabaseClient client = newSslClient(Map.of( + "marklogic.client.sslContext", sslContext, + "marklogic.client.trustManager", Common.TRUST_ALL_MANAGER, + "marklogic.client.sslHostnameVerifier", DatabaseClientFactory.SSLHostnameVerifier.ANY + )); DatabaseClient.ConnectionResult result = client.checkConnection(); assertEquals(0, result.getStatusCode(), "A value of zero implies that a connection was successfully made, " + @@ -91,11 +95,11 @@ void trustAllManager() throws Exception { */ @Test void trustManagerThatOnlyTrustsTheCertificateFromTheCertificateTemplate() { - DatabaseClient client = Common.newClientBuilder() - .withSSLProtocol(SSLUtil.DEFAULT_PROTOCOL) - .withTrustManager(RequireSSLExtension.newSecureTrustManager()) - .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) - .build(); + DatabaseClient client = newSslClient(Map.of( + "marklogic.client.sslProtocol", SSLUtil.DEFAULT_PROTOCOL, + "marklogic.client.trustManager", RequireSSLExtension.newSecureTrustManager(), + "marklogic.client.sslHostnameVerifier", DatabaseClientFactory.SSLHostnameVerifier.ANY + )); DatabaseClient.ConnectionResult result = client.checkConnection(); assertEquals(0, result.getStatusCode()); @@ -104,11 +108,11 @@ void trustManagerThatOnlyTrustsTheCertificateFromTheCertificateTemplate() { @Test void defaultSslContext() throws Exception { - DatabaseClient client = Common.newClientBuilder() - .withSSLContext(SSLContext.getDefault()) - .withTrustManager(Common.TRUST_ALL_MANAGER) - .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) - .build(); + DatabaseClient client = newSslClient(Map.of( + "marklogic.client.sslContext", SSLContext.getDefault(), + "marklogic.client.trustManager", Common.TRUST_ALL_MANAGER, + "marklogic.client.sslHostnameVerifier", DatabaseClientFactory.SSLHostnameVerifier.ANY + )); MarkLogicIOException ex = assertThrows(MarkLogicIOException.class, () -> client.checkConnection(), "The connection should fail because the JVM's default SSL Context does not have a CA certificate that " + @@ -119,7 +123,7 @@ void defaultSslContext() throws Exception { @ExtendWith(RequiresML11OrLower.class) @Test void noSslContext() { - DatabaseClient client = Common.newClientBuilder().build(); + DatabaseClient client = newSslClient(Map.of()); DatabaseClient.ConnectionResult result = client.checkConnection(); assertEquals("Forbidden", result.getErrorMessage(), "MarkLogic is expected to return a 403 Forbidden when the " + @@ -142,7 +146,7 @@ void noSslContext() { @ExtendWith(RequiresML12.class) @Test void noSslContextWithMarkLogic12() { - DatabaseClient client = Common.newClientBuilder().build(); + DatabaseClient client = newSslClient(Map.of()); MarkLogicIOException ex = assertThrows(MarkLogicIOException.class, () -> client.checkConnection()); assertTrue(ex.getMessage().contains("unexpected end of stream"), "Per MLE-17505, a change in the openssl " + @@ -185,11 +189,59 @@ void tLS12ClientWithTLS13ServerShouldFail() { assertEquals(expected, ex.getMessage()); } + /** + * Verifies that a {@link DatabaseClient} backed by a truststore that does NOT contain + * the server's CA certificate fails with an {@link SSLHandshakeException}. This confirms + * that proper certificate validation is active — i.e. the trust manager is not a no-op. + * + *

The JVM default CA bundle is used as the trust store. It contains real certificates + * but not the test-only MarkLogic CA, so the TLS handshake starts and then fails with + * {@link SSLHandshakeException} when the server's certificate cannot be verified.

+ */ + @Test + void untrustedCertificateThrowsSSLHandshakeException() throws Exception { + // Use the JVM default trust managers (standard CA bundle). The MarkLogic + // test certificate is issued by a test CA that is not present in that bundle, + // so the TLS handshake will fail with SSLHandshakeException. + TrustManager[] trustManagers = SSLUtil.getDefaultTrustManagers(); + SSLContext sslContext = SSLContext.getInstance(SSLUtil.DEFAULT_PROTOCOL); + sslContext.init(null, trustManagers, null); + + DatabaseClient client = newSslClient(Map.of( + "marklogic.client.sslContext", sslContext, + "marklogic.client.trustManager", (X509TrustManager) trustManagers[0], + "marklogic.client.sslHostnameVerifier", DatabaseClientFactory.SSLHostnameVerifier.ANY + )); + + MarkLogicIOException ex = assertThrows(MarkLogicIOException.class, () -> client.checkConnection(), + "Connection must fail because the JVM default trust store does not contain the test-only MarkLogic CA certificate"); + assertTrue(ex.getCause() instanceof SSLHandshakeException, + "Expected SSLHandshakeException caused by an untrusted server certificate; actual cause: " + ex.getCause()); + } + + private DatabaseClient newSslClient(Map sslProps) { + return DatabaseClientFactory.newClient(propertyName -> { + if (sslProps.containsKey(propertyName)) { + return sslProps.get(propertyName); + } + return switch (propertyName) { + case "marklogic.client.host" -> Common.HOST; + case "marklogic.client.port" -> Common.PORT; + case "marklogic.client.basePath" -> Common.BASE_PATH; + case "marklogic.client.authType" -> Common.AUTH_TYPE; + case "marklogic.client.username" -> Common.USER; + case "marklogic.client.password" -> Common.PASS; + case "marklogic.client.connectionType" -> Common.CONNECTION_TYPE; + default -> null; + }; + }); + } + DatabaseClient buildTrustAllClientWithSSLProtocol(String sslProtocol) { - return Common.newClientBuilder() - .withSSLProtocol(sslProtocol) - .withTrustManager(Common.TRUST_ALL_MANAGER) - .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) - .build(); + return newSslClient(Map.of( + "marklogic.client.sslProtocol", sslProtocol, + "marklogic.client.trustManager", Common.TRUST_ALL_MANAGER, + "marklogic.client.sslHostnameVerifier", DatabaseClientFactory.SSLHostnameVerifier.ANY + )); } }