diff --git a/gradle.properties b/gradle.properties index c468e05ee..2315a868c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=com.marklogic -version=6.3-SNAPSHOT +version=6.4-SNAPSHOT describedName=MarkLogic Java Client API publishUrl=file:../marklogic-java/releases diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java index 1d8606fc2..219a59ab6 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java @@ -259,6 +259,48 @@ public DatabaseClientBuilder withGzippedResponsesDisabled() { props.put(PREFIX + "disableGzippedResponses", true); return this; } + + /** + * Enables 2-way SSL by creating an SSL context based on the given key store path. + * + * @param path + * @return + * @since 6.4.0 + */ + public DatabaseClientBuilder withKeyStorePath(String path) { + props.put(PREFIX + "ssl.keystore.path", path); + return this; + } + + /** + * @param password optional password for a key store + * @return + * @since 6.4.0 + */ + public DatabaseClientBuilder withKeyStorePassword(String password) { + props.put(PREFIX + "ssl.keystore.password", password); + return this; + } + + /** + * @param type e.g. "JKS" + * @return + * @since 6.4.0 + */ + public DatabaseClientBuilder withKeyStoreType(String type) { + props.put(PREFIX + "ssl.keystore.type", type); + return this; + } + + /** + * @param algorithm e.g. "SunX509" + * @return + * @since 6.4.0 + */ + public DatabaseClientBuilder withKeyStoreAlgorithm(String algorithm) { + props.put(PREFIX + "ssl.keystore.algorithm", algorithm); + return this; + } } diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientFactory.java b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientFactory.java index cf18e6b26..0f01216c6 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientFactory.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientFactory.java @@ -1299,6 +1299,10 @@ public String getCertificatePassword() { * a String with a value of either "any", "common", or "strict" *
  • marklogic.client.trustManager = must be an instance of {@code javax.net.ssl.X509TrustManager}; * if not specified and an SSL context is configured, an attempt will be made to use the JVM's default trust manager
  • + *
  • marklogic.client.ssl.keystore.path = must be a String; enables 2-way SSL if set; since 6.4.0.
  • + *
  • marklogic.client.ssl.keystore.password = must be a String; optional password for a key store; since 6.4.0.
  • + *
  • marklogic.client.ssl.keystore.type = must be a String; optional type for a key store, defaults to "JKS"; since 6.4.0.
  • + *
  • marklogic.client.ssl.keystore.algorithm = must be a String; optional algorithm for a key store, defaults to "SunX509"; since 6.4.0.
  • * * * @param propertySource diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java index 112bb304a..b36f0b8aa 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java @@ -151,7 +151,7 @@ private DatabaseClientFactory.SecurityContext newSecurityContext() { } final String authType = (String) typeValue; - final SSLInputs sslInputs = buildSSLInputs(authType); + final SSLUtil.SSLInputs sslInputs = buildSSLInputs(authType); DatabaseClientFactory.SecurityContext securityContext = newSecurityContext(authType, sslInputs); if (sslInputs.getSslContext() != null) { securityContext.withSSLContext(sslInputs.getSslContext(), sslInputs.getTrustManager()); @@ -160,7 +160,7 @@ private DatabaseClientFactory.SecurityContext newSecurityContext() { return securityContext; } - private DatabaseClientFactory.SecurityContext newSecurityContext(String type, SSLInputs sslInputs) { + private DatabaseClientFactory.SecurityContext newSecurityContext(String type, SSLUtil.SSLInputs sslInputs) { switch (type.toLowerCase()) { case DatabaseClientBuilder.AUTH_TYPE_BASIC: return newBasicAuthContext(); @@ -188,11 +188,15 @@ private String getRequiredStringValue(String propertyName) { } private String getNullableStringValue(String propertyName) { + return getNullableStringValue(propertyName, null); + } + + private String getNullableStringValue(String propertyName, String defaultValue) { Object value = propertySource.apply(PREFIX + propertyName); if (value != null && !(value instanceof String)) { throw new IllegalArgumentException(propertyName + " must be of type String"); } - return (String) value; + return value != null ? (String) value : defaultValue; } private DatabaseClientFactory.SecurityContext newBasicAuthContext() { @@ -221,7 +225,7 @@ private DatabaseClientFactory.SecurityContext newCloudAuthContext() { return new DatabaseClientFactory.MarkLogicCloudAuthContext(apiKey, duration); } - private DatabaseClientFactory.SecurityContext newCertificateAuthContext(SSLInputs sslInputs) { + private DatabaseClientFactory.SecurityContext newCertificateAuthContext(SSLUtil.SSLInputs sslInputs) { String file = getNullableStringValue("certificate.file"); String password = getNullableStringValue("certificate.password"); if (file != null && file.trim().length() > 0) { @@ -234,6 +238,9 @@ private DatabaseClientFactory.SecurityContext newCertificateAuthContext(SSLInput throw new RuntimeException("Unable to create CertificateAuthContext; cause " + e.getMessage(), e); } } + if (sslInputs.getSslContext() == null) { + throw new RuntimeException("An SSLContext is required for certificate authentication."); + } return new DatabaseClientFactory.CertificateAuthContext(sslInputs.getSslContext(), sslInputs.getTrustManager()); } @@ -271,18 +278,24 @@ private DatabaseClientFactory.SSLHostnameVerifier determineHostnameVerifier() { * case the user does not define their own SSLContext or SSL protocol * @return */ - private SSLInputs buildSSLInputs(String authType) { + private SSLUtil.SSLInputs buildSSLInputs(String authType) { X509TrustManager userTrustManager = getTrustManager(); // Approach 1 - user provides an SSLContext object, in which case there's nothing further to check. SSLContext sslContext = getSSLContext(); if (sslContext != null) { - return new SSLInputs(sslContext, userTrustManager); + return new SSLUtil.SSLInputs(sslContext, userTrustManager); } - // Approaches 2 and 3 - user defines an SSL protocol. - // Approach 2 - "default" is a convenience for using the JVM's default SSLContext. - // Approach 3 - create a new SSLContext, and initialize it if the user-provided TrustManager is not null. + // Approach 2 - user wants two-way SSL via a keystore. + final String keyStorePath = getNullableStringValue("ssl.keystore.path"); + if (keyStorePath != null && keyStorePath.trim().length() > 0) { + return useKeyStoreForTwoWaySSL(keyStorePath, userTrustManager); + } + + // Approaches 3 and 4 - user defines an SSL protocol. + // Approach 3 - "default" is a convenience for using the JVM's default SSLContext. + // Approach 4 - create a new SSLContext, and initialize it if the user-provided TrustManager is not null. final String sslProtocol = getSSLProtocol(authType); if (sslProtocol != null) { return "default".equalsIgnoreCase(sslProtocol) ? @@ -290,9 +303,9 @@ private SSLInputs buildSSLInputs(String authType) { useNewSSLContext(sslProtocol, userTrustManager); } - // Approach 4 - still return the user-defined TrustManager as that may be needed for certificate authentication, + // Approach 5 - still return the user-defined TrustManager as that may be needed for certificate authentication, // which has its own way of constructing an SSLContext from a PKCS12 file. - return new SSLInputs(null, userTrustManager); + return new SSLUtil.SSLInputs(null, userTrustManager); } private X509TrustManager getTrustManager() { @@ -332,11 +345,20 @@ private String getSSLProtocol(String authType) { return sslProtocol; } + private SSLUtil.SSLInputs useKeyStoreForTwoWaySSL(String keyStorePath, X509TrustManager userTrustManager) { + final String password = getNullableStringValue("ssl.keystore.password"); + final String keyStoreType = getNullableStringValue("ssl.keystore.type", "JKS"); + final String algorithm = getNullableStringValue("ssl.keystore.algorithm", "SunX509"); + final char[] charPassword = password != null ? password.toCharArray() : null; + final String sslProtocol = getNullableStringValue("sslProtocol", "TLSv1.2"); + return SSLUtil.createSSLContextFromKeyStore(keyStorePath, charPassword, keyStoreType, algorithm, sslProtocol, userTrustManager); + } + /** * Uses the JVM's default SSLContext. Because OkHttp requires a separate TrustManager, this approach will either * user the user-provided TrustManager or it will assume that the JVM's default TrustManager should be used. */ - private SSLInputs useDefaultSSLContext(X509TrustManager userTrustManager) { + private SSLUtil.SSLInputs useDefaultSSLContext(X509TrustManager userTrustManager) { SSLContext sslContext; try { sslContext = SSLContext.getDefault(); @@ -344,7 +366,7 @@ private SSLInputs useDefaultSSLContext(X509TrustManager userTrustManager) { throw new RuntimeException("Unable to obtain default SSLContext; cause: " + e.getMessage(), e); } X509TrustManager trustManager = userTrustManager != null ? userTrustManager : SSLUtil.getDefaultTrustManager(); - return new SSLInputs(sslContext, trustManager); + return new SSLUtil.SSLInputs(sslContext, trustManager); } /** @@ -352,7 +374,7 @@ private SSLInputs useDefaultSSLContext(X509TrustManager userTrustManager) { * the user's TrustManager is not null. Otherwise, OkHttpUtil will eventually initialize the SSLContext using the * JVM's default TrustManager. */ - private SSLInputs useNewSSLContext(String sslProtocol, X509TrustManager userTrustManager) { + private SSLUtil.SSLInputs useNewSSLContext(String sslProtocol, X509TrustManager userTrustManager) { SSLContext sslContext; try { sslContext = SSLContext.getInstance(sslProtocol); @@ -368,27 +390,6 @@ private SSLInputs useNewSSLContext(String sslProtocol, X509TrustManager userTrus sslProtocol, e.getMessage()), e); } } - return new SSLInputs(sslContext, userTrustManager); - } - - /** - * Captures the inputs provided by the caller that pertain to constructing an SSLContext. - */ - private static class SSLInputs { - private final SSLContext sslContext; - private final X509TrustManager trustManager; - - public SSLInputs(SSLContext sslContext, X509TrustManager trustManager) { - this.sslContext = sslContext; - this.trustManager = trustManager; - } - - public SSLContext getSslContext() { - return sslContext; - } - - public X509TrustManager getTrustManager() { - return trustManager; - } + return new SSLUtil.SSLInputs(sslContext, userTrustManager); } } diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java index 217f850dd..099b51c38 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java @@ -18,20 +18,31 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; +import javax.net.ssl.*; +import java.io.FileInputStream; +import java.io.InputStream; +import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -public interface SSLUtil { +/** + * SSL convenience methods that are stored in the "impl" package, but we may eventually want to make these officially + * public, particular for reuse in connectors. + */ +public abstract class SSLUtil { - static X509TrustManager getDefaultTrustManager() { + /** + * @return an X509TrustManager based on the JVM's default trust manager algorithm. How this is constructed can vary + * based on the JVM type and version. One common approach is for the JVM to constructs this based on its + * ./jre/lib/security/cacerts file. + */ + public static X509TrustManager getDefaultTrustManager() { X509TrustManager trustManager = (X509TrustManager) getDefaultTrustManagers()[0]; Logger logger = LoggerFactory.getLogger(SSLUtil.class); if (logger.isDebugEnabled() && trustManager.getAcceptedIssuers() != null) { - logger.debug("Count of accepted issuers in default trust manager: {}", trustManager.getAcceptedIssuers().length); + logger.debug("Count of accepted issuers in default trust manager: {}", + trustManager.getAcceptedIssuers().length); } return trustManager; } @@ -40,29 +51,140 @@ static X509TrustManager getDefaultTrustManager() { * @return a non-empty array of TrustManager instances based on the JVM's default trust manager algorithm, with the * first trust manager guaranteed to be an instance of X509TrustManager. */ - static TrustManager[] getDefaultTrustManagers() { + public static TrustManager[] getDefaultTrustManagers() { final String defaultAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); + return getTrustManagers(defaultAlgorithm, null); + } + + /** + * @param trustManagerAlgorithm e.g. "SunX509". + * @param optionalKeyStore if not null, used to initialize the TrustManagerFactory constructed based on the + * given algorithm. + * @return + */ + public static TrustManager[] getTrustManagers(String trustManagerAlgorithm, KeyStore optionalKeyStore) { TrustManagerFactory trustManagerFactory; try { - trustManagerFactory = TrustManagerFactory.getInstance(defaultAlgorithm); + trustManagerFactory = TrustManagerFactory.getInstance(trustManagerAlgorithm); } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("Unable to obtain trust manager factory using JVM's default trust manager algorithm: " + defaultAlgorithm, e); + throw new RuntimeException( + "Unable to obtain trust manager factory using algorithm: " + trustManagerAlgorithm, e); } try { - trustManagerFactory.init((KeyStore) null); + trustManagerFactory.init(optionalKeyStore); } catch (KeyStoreException e) { - throw new RuntimeException("Unable to initialize trust manager factory obtained using JVM's default trust manager algorithm: " + defaultAlgorithm - + "; cause: " + e.getMessage(), e); + throw new RuntimeException(String.format( + "Unable to initialize trust manager factory obtained using algorithm: %s; cause: %s", + trustManagerAlgorithm, e.getMessage()), e); } TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); if (trustManagers == null || trustManagers.length == 0) { - throw new RuntimeException("No trust managers found using the JVM's default trust manager algorithm: " + defaultAlgorithm); + throw new RuntimeException("No trust managers found using algorithm: " + trustManagerAlgorithm); } if (!(trustManagers[0] instanceof X509TrustManager)) { throw new RuntimeException("Default trust manager is not an X509TrustManager: " + trustManagers[0]); } return trustManagers; } + + /** + * Captures the oft-repeated boilerplate Java code for creating an SSLContext based on a key store. + * + * @param keyStorePath required path to a key store file + * @param keyStorePassword optional password, can be null + * @param keyStoreType type of key store, e.g. "JKS" + * @param algorithm key store algorithm, e.g. "SunX509" + * @param sslProtocol e.g. "TLSv1.2" + * @param userProvidedTrustManager optional trust manager provided by a user; if not null, will be used to + * initialize the SSLContext instead of using the key store as a trust manager. + * @return + */ + static SSLInputs createSSLContextFromKeyStore(String keyStorePath, char[] keyStorePassword, String keyStoreType, + String algorithm, String sslProtocol, + X509TrustManager userProvidedTrustManager) { + + KeyStore keyStore = getKeyStore(keyStorePath, keyStorePassword, keyStoreType); + KeyManagerFactory keyManagerFactory = newKeyManagerFactory(keyStore, keyStorePassword, algorithm); + SSLContext sslContext = newSSLContext(sslProtocol); + + TrustManager[] trustManagers = userProvidedTrustManager != null + ? new X509TrustManager[]{userProvidedTrustManager} + : getTrustManagers(algorithm, keyStore); + + try { + sslContext.init(keyManagerFactory.getKeyManagers(), trustManagers, null); + } catch (KeyManagementException ex) { + throw new RuntimeException("Unable to initialize SSL context", ex); + } + + return new SSLInputs(sslContext, (X509TrustManager) trustManagers[0]); + } + + /** + * @return a Java KeyStore based on the given inputs. + */ + public static KeyStore getKeyStore(String keyStorePath, char[] keyStorePassword, String keyStoreType) { + KeyStore keyStore; + try { + keyStore = KeyStore.getInstance(keyStoreType); + } catch (KeyStoreException ex) { + throw new RuntimeException("Unable to get instance of key store with type: " + keyStoreType, ex); + } + + try (InputStream inputStream = new FileInputStream(keyStorePath)) { + keyStore.load(inputStream, keyStorePassword); + return keyStore; + } catch (Exception ex) { + throw new RuntimeException("Unable to read from key store at path: " + keyStorePath, ex); + } + } + + private static KeyManagerFactory newKeyManagerFactory(KeyStore keyStore, char[] keyStorePassword, String algorithm) { + KeyManagerFactory keyManagerFactory; + try { + keyManagerFactory = KeyManagerFactory.getInstance(algorithm); + } catch (NoSuchAlgorithmException ex) { + throw new RuntimeException("Unable to create key manager factory with algorithm: " + algorithm, ex); + } + + try { + keyManagerFactory.init(keyStore, keyStorePassword); + } catch (Exception ex) { + throw new RuntimeException("Unable to initialize key manager factory", ex); + } + return keyManagerFactory; + } + + private static SSLContext newSSLContext(String sslProtocol) { + try { + return SSLContext.getInstance(sslProtocol); + } catch (Exception ex) { + throw new RuntimeException("Unable to create SSL context using protocol: " + sslProtocol, ex); + } + } + + /** + * Captures the inputs needed by the Java Client for establishing an SSL connection. The need for a separate + * X509TrustManager arose from the switch from Apache's HttpClient to OkHttp, where the latter needs access to a + * X509TrustManager (as opposed to relying on any trust managers within an SSLContext). + */ + public static class SSLInputs { + private final SSLContext sslContext; + private final X509TrustManager trustManager; + + public SSLInputs(SSLContext sslContext, X509TrustManager trustManager) { + this.sslContext = sslContext; + this.trustManager = trustManager; + } + + public SSLContext getSslContext() { + return sslContext; + } + + public X509TrustManager getTrustManager() { + return trustManager; + } + } } diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/TwoWaySSLTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/TwoWaySSLTest.java index 375a7e211..e454a56c4 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/TwoWaySSLTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/ssl/TwoWaySSLTest.java @@ -23,22 +23,16 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.X509TrustManager; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; +import java.io.*; import java.nio.file.Path; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; @ExtendWith(RequireSSLExtension.class) public class TwoWaySSLTest { @@ -96,15 +90,19 @@ public static void teardown() { * SSLContext can connect to the app server. */ @Test - void digestAuthentication() throws Exception { + void digestAuthentication() { if (Common.USE_REVERSE_PROXY_SERVER) { return; } // This client uses our Java KeyStore file with a client certificate in it, so it should work. DatabaseClient clientWithCert = Common.newClientBuilder() + .withKeyStorePath(keyStoreFile.getAbsolutePath()) + .withKeyStorePassword(KEYSTORE_PASSWORD) + // Still need this as "common"/"strict" don't work for our temporary server certificate. .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) - .withSSLContext(createSSLContextWithClientCertificate(keyStoreFile)) + // This is a reasonable trust manager since it references the temporary server certificate as something + // that it accepts instead of accepting everything. .withTrustManager(RequireSSLExtension.newSecureTrustManager()) .build(); @@ -129,6 +127,51 @@ void digestAuthentication() throws Exception { "Unexpected exception: " + userException.getMessage()); } + @Test + void invalidKeyStoreType() { + RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() + .withKeyStoreType("Not a valid type!") + .withKeyStorePath("doesn't matter for this test") + .build()); + + assertEquals("Unable to get instance of key store with type: Not a valid type!", ex.getMessage()); + assertTrue(ex.getCause() instanceof KeyStoreException); + } + + @Test + void invalidKeyStorePath() { + RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() + .withKeyStorePath("/no/keystore/here.txt").build()); + + assertEquals("Unable to read from key store at path: /no/keystore/here.txt", ex.getMessage()); + assertTrue(ex.getCause() instanceof FileNotFoundException); + } + + @Test + void invalidKeyStorePassword() { + RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() + .withKeyStorePath(keyStoreFile.getAbsolutePath()) + .withKeyStorePassword("wrong password!") + .build()); + + assertTrue(ex.getMessage().startsWith("Unable to read from key store at path:"), + "Unexpected message: " + ex.getMessage()); + assertTrue(ex.getCause() instanceof IOException); + } + + @Test + void invalidKeyStoreAlgorithm() { + RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() + .withKeyStorePath(keyStoreFile.getAbsolutePath()) + .withKeyStorePassword(KEYSTORE_PASSWORD) + .withKeyStoreAlgorithm("Not a valid algorithm!") + .build()); + + assertEquals("Unable to create key manager factory with algorithm: Not a valid algorithm!", ex.getMessage()); + assertTrue(ex.getCause() instanceof NoSuchAlgorithmException); + } + + /** * Verifies certificate authentication when a user provides their own SSLContext. */ @@ -176,6 +219,16 @@ void certificateAuthenticationWithCertificateFileAndPassword() { } } + @Test + void certificateAuthenticationWithNoSSLContextOrFileAndPassword() { + RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() + .withCertificateAuth(null, RequireSSLExtension.newSecureTrustManager()) + .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) + .build()); + + assertEquals("An SSLContext is required for certificate authentication.", ex.getMessage()); + } + private void setAuthenticationToCertificate() { new ServerManager(manageClient) .save(Common.newServerPayload().put("authentication", "certificate").toString());