From b9b5310f7936ecef8d57b7191140e8373a4faa00 Mon Sep 17 00:00:00 2001 From: Ryan Dew Date: Thu, 18 Jun 2026 14:38:54 -0700 Subject: [PATCH] MLE-30239 Use secure trust store --- .../example/cookbook/SSLClientCreator.java | 94 +++++++++---------- .../client/example/cookbook/Util.java | 6 +- .../src/main/resources/Example.properties | 6 ++ .../functionaltest/ConnectedRESTQA.java | 49 ++++++---- .../src/test/resources/test.properties | 6 ++ .../client/test/ssl/OneWaySSLTest.java | 34 ++++++- 6 files changed, 121 insertions(+), 74 deletions(-) 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..3b2639400 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,15 +1,19 @@ /* - * 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.FileInputStream; import java.io.IOException; import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; +import java.security.cert.CertificateException; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import com.marklogic.client.DatabaseClient; @@ -23,70 +27,56 @@ /** * SSLClientCreator illustrates the basic approach for creating a client using SSL for database access. * - * Note: to run this example, you must modify the REST server by specifying a SSL certificate template. + *

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 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, KeyManagementException, NoSuchAlgorithmException, + KeyStoreException, CertificateException { run(Util.loadProperties()); } - public static void run(ExampleProperties props) throws NoSuchAlgorithmException, KeyManagementException { + public static void run(ExampleProperties props) throws NoSuchAlgorithmException, KeyManagementException, + KeyStoreException, CertificateException, IOException { 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) { - } + // Load the truststore containing the server's CA certificate. + // 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."); + } + KeyStore trustStore = KeyStore.getInstance("JKS"); + try (FileInputStream fis = new FileInputStream(props.trustStorePath)) { + trustStore.load(fis, props.trustStorePassword.toCharArray()); + } - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } - }; + // Initialise a TrustManagerFactory from the truststore so that the SSLContext + // will validate the server's certificate against the trusted CAs it contains. + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + TrustManager[] trustManagers = tmf.getTrustManagers(); + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; - // create an SSL context + // Create an SSL context backed by the real trust manager. 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); + sslContext.init(null, trustManagers, null); - // create the client - // (note: a real application should use a COMMON, STRICT, or implemented hostname verifier) + // Create the client using STRICT hostname verification so that the server's + // certificate CN/SANs are checked against the host being connected to. DatabaseClient client = DatabaseClientFactory.newClient( props.host, props.port, new DigestAuthContext(props.writerUser, props.writerPassword) - .withSSLContext(sslContext, (X509TrustManager) naiveTrustMgr[0]) - .withSSLHostnameVerifier(SSLHostnameVerifier.ANY)); + .withSSLContext(sslContext, trustManager) + .withSSLHostnameVerifier(SSLHostnameVerifier.STRICT)); // make use of the client connection TextDocumentManager docMgr = client.newTextDocumentManager(); 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..24a9ce3b4 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,9 @@ 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 static org.junit.jupiter.api.Assertions.*; @@ -185,6 +187,36 @@ 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 = Common.newClientBuilder() + .withSSLContext(sslContext) + .withTrustManager((X509TrustManager) trustManagers[0]) + .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) + .build(); + + 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()); + } + DatabaseClient buildTrustAllClientWithSSLProtocol(String sslProtocol) { return Common.newClientBuilder() .withSSLProtocol(sslProtocol)