diff --git a/README.md b/README.md index ca4d7a1..5f93b8a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ jdbc:redis:cluster://[[:]@][[:],[:], | clientName | String | null | | | ssl | Boolean | false | Enable SSL. | | verifyServerCertificate | Boolean | true | Configure a connection that uses SSL but does not verify the identity of the server. | +| truststorePath | String | null | Path to truststore file for server certificate validation. | +| truststorePassword | String | null | Password for truststore file. | +| truststoreType | String | JKS | Truststore type (JKS, PKCS12, etc.). | +| keystorePath | String | null | Path to keystore file for client certificate authentication. | +| keystorePassword | String | null | Password for keystore file. | +| keystoreType | String | JKS | Keystore type (JKS, PKCS12, etc.). | | hostAndPortMapping | Map | null | | | verifyConnectionMode | Boolean | true | Verify that the mode specified for a connection in the URL prefix matches the server mode (standalone, cluster, sentinel). | @@ -97,11 +103,22 @@ jdbc:redis:cluster://[[:]@][[:],[:], Set the property `ssl` to `true`. -Pass arguments for your keystore and trust store: +Configure keystore and truststore using connection properties or system properties (as fallback): + +``` +jdbc:redis://[[:]@][[:]][/]?ssl=true&truststorePath=/path/to/client.truststore&truststorePassword=password&=&=&...] +``` + +For client authentication (mutual TLS), also provide keystore: + +``` +jdbc:redis://[[:]@][[:]][/]?ssl=true&keystorePath=/path/to/client.keystore&keystorePassword=password&=&=&...] +``` + +System properties are used as fallback if connection properties are not provided: ``` -Djavax.net.ssl.trustStore=/path/to/client.truststore -Djavax.net.ssl.trustStorePassword=password123 -# If you're using client authentication: -Djavax.net.ssl.keyStore=/path/to/client.keystore -Djavax.net.ssl.keyStorePassword=password123 ``` diff --git a/build.gradle b/build.gradle index bac6862..003ca56 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,9 @@ sourceSets { java { srcDirs = ['driver/src/test'] } + resources { + srcDirs = ['driver/src/test/resources'] + } } } diff --git a/driver/src/main/java/jdbc/client/impl/RedisJedisURIBase.java b/driver/src/main/java/jdbc/client/impl/RedisJedisURIBase.java index c0a9114..404ea3d 100644 --- a/driver/src/main/java/jdbc/client/impl/RedisJedisURIBase.java +++ b/driver/src/main/java/jdbc/client/impl/RedisJedisURIBase.java @@ -21,6 +21,7 @@ import static jdbc.properties.RedisDefaultConfig.CONFIG; import static jdbc.properties.RedisDriverPropertyInfoHelper.*; import static jdbc.utils.SSLUtils.getTrustEverybodySSLContext; +import static jdbc.utils.SSLUtils.getValidatingSSLContext; import static jdbc.utils.Utils.*; public abstract class RedisJedisURIBase implements JedisClientConfig { @@ -104,8 +105,20 @@ protected RedisJedisURIBase(String url, Properties info) throws SQLException { return url.replaceFirst(prefix, ""); } - protected abstract @NotNull String getPrefix(); + private String validateAndGetUrl(String path) { + String url = path; + if (!isNullOrEmpty(url)) { + try { + new URL(url); + } + catch (MalformedURLException e) { + url = "file:" + url; + } + } + return url; + } + protected abstract @NotNull String getPrefix(); private void setAuth(@NotNull String authBlock, Properties info) { String user = CONFIG.getUser(); @@ -166,20 +179,37 @@ private void setSSLParameters(@NotNull Map parameters, Propertie ssl = getBoolean(parameters, info, SSL, CONFIG.isSsl()); if (ssl) { boolean verifyServerCertificate = getBoolean(parameters, info, VERIFY_SERVER_CERTIFICATE, CONFIG.isVerifyServerCertificate()); + + // Read keystore properties for client certificate authentication (used in both branches) + String keystorePath = getString(parameters, info, KEYSTORE_PATH, System.getProperty("javax.net.ssl.keyStore", "")); + String keystorePassword + = getString(parameters, info, KEYSTORE_PASSWORD, System.getProperty("javax.net.ssl.keyStorePassword", "")); + String keystoreType = getString(parameters, info, KEYSTORE_TYPE, + System.getProperty("javax.net.ssl.keyStoreType", KeyStore.getDefaultType())); + String keystoreUrl = validateAndGetUrl(keystorePath); + if (!verifyServerCertificate) { - String keyStoreType = System.getProperty("javax.net.ssl.keyStoreType", KeyStore.getDefaultType()); - String keyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword", ""); - String keyStoreUrl = System.getProperty("javax.net.ssl.keyStore", ""); - // check keyStoreUrl - if (!isNullOrEmpty(keyStoreUrl)) { - try { - new URL(keyStoreUrl); - } catch (MalformedURLException e) { - keyStoreUrl = "file:" + keyStoreUrl; - } - } - SSLContext context = getTrustEverybodySSLContext(keyStoreUrl, keyStoreType, keyStorePassword); + // For verifyServerCertificate=false, create custom SSLContext that trusts everything + // but still loads client certificate from keystore if provided + SSLContext context = getTrustEverybodySSLContext(keystoreUrl, keystoreType, keystorePassword); sslSocketFactory = context.getSocketFactory(); + } else { + // Read truststore properties (only needed for verifyServerCertificate=true) + String truststorePath = getString(parameters, info, TRUSTSTORE_PATH, System.getProperty("javax.net.ssl.trustStore", "")); + String truststorePassword + = getString(parameters, info, TRUSTSTORE_PASSWORD, System.getProperty("javax.net.ssl.trustStorePassword", "")); + String truststoreType = getString(parameters, info, TRUSTSTORE_TYPE, + System.getProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType())); + String truststoreUrl = validateAndGetUrl(truststorePath); + + if (!isNullOrEmpty(truststoreUrl) || !isNullOrEmpty(keystoreUrl)) { + // Custom truststore or keystore provided - create validating SSLContext + SSLContext context = getValidatingSSLContext(truststoreUrl, truststoreType, truststorePassword, + keystoreUrl, keystoreType, keystorePassword); + sslSocketFactory = context.getSocketFactory(); + } + // else: No custom truststore/keystore - leave sslSocketFactory as null + // Jedis will use SSLContext.getDefault() with JVM's default truststore } } } diff --git a/driver/src/main/java/jdbc/properties/RedisDriverPropertyInfoHelper.java b/driver/src/main/java/jdbc/properties/RedisDriverPropertyInfoHelper.java index 945476a..3e60701 100644 --- a/driver/src/main/java/jdbc/properties/RedisDriverPropertyInfoHelper.java +++ b/driver/src/main/java/jdbc/properties/RedisDriverPropertyInfoHelper.java @@ -1,5 +1,6 @@ package jdbc.properties; +import java.security.KeyStore; import java.sql.DriverPropertyInfo; import java.util.ArrayList; @@ -17,6 +18,12 @@ public class RedisDriverPropertyInfoHelper { public static final String MAX_ATTEMPTS = "maxAttempts"; public static final String SSL = "ssl"; public static final String VERIFY_SERVER_CERTIFICATE = "verifyServerCertificate"; + public static final String TRUSTSTORE_PATH = "truststorePath"; + public static final String TRUSTSTORE_PASSWORD = "truststorePassword"; + public static final String TRUSTSTORE_TYPE = "truststoreType"; + public static final String KEYSTORE_PATH = "keystorePath"; + public static final String KEYSTORE_PASSWORD = "keystorePassword"; + public static final String KEYSTORE_TYPE = "keystoreType"; public static final String HOST_AND_PORT_MAPPING = "hostAndPortMapping"; public static final String HOST_AND_PORT_MAPPING_DEFAULT = null; @@ -43,6 +50,12 @@ public static DriverPropertyInfo[] getPropertyInfo() { addPropInfo(propInfos, SSL, String.valueOf(CONFIG.isSsl()), "Enable SSL.", booleanChoices); addPropInfo(propInfos, VERIFY_SERVER_CERTIFICATE, String.valueOf(CONFIG.isVerifyServerCertificate()), "Configure a connection that uses SSL but does not verify the identity of the server.", booleanChoices); + addPropInfo(propInfos, TRUSTSTORE_PATH, null, "Path to truststore file for server certificate validation."); + addPropInfo(propInfos, TRUSTSTORE_PASSWORD, null, "Password for truststore file."); + addPropInfo(propInfos, TRUSTSTORE_TYPE, KeyStore.getDefaultType(), "Truststore type (default: " + KeyStore.getDefaultType() + ")."); + addPropInfo(propInfos, KEYSTORE_PATH, null, "Path to keystore file for client certificate authentication."); + addPropInfo(propInfos, KEYSTORE_PASSWORD, null, "Password for keystore file."); + addPropInfo(propInfos, KEYSTORE_TYPE, KeyStore.getDefaultType(), "Keystore type (default: " + KeyStore.getDefaultType() + ")."); addPropInfo(propInfos, HOST_AND_PORT_MAPPING, HOST_AND_PORT_MAPPING_DEFAULT, "Host and port mapping."); addPropInfo(propInfos, VERIFY_CONNECTION_MODE, String.valueOf(VERIFY_CONNECTION_MODE_DEFAULT), "Verify that the mode specified for a connection in the URL prefix matches the server mode (standalone, cluster, sentinel).", booleanChoices); diff --git a/driver/src/main/java/jdbc/utils/SSLUtils.java b/driver/src/main/java/jdbc/utils/SSLUtils.java index 5b12b97..1005b85 100644 --- a/driver/src/main/java/jdbc/utils/SSLUtils.java +++ b/driver/src/main/java/jdbc/utils/SSLUtils.java @@ -15,62 +15,50 @@ public class SSLUtils { public static SSLContext getTrustEverybodySSLContext(String clientCertificateKeyStoreUrl, String clientCertificateKeyStoreType, String clientCertificateKeyStorePassword) throws SSLParamsException { - KeyManagerFactory kmf; + // Delegate to unified method with trust-all flag + TrustManager[] tms = new TrustManager[] { new MyTrustEverybodyManager() }; + // Load KeyManagers (for client certificate authentication) KeyManager[] kms = null; + if (!isNullOrEmpty(clientCertificateKeyStoreUrl)) { + kms = loadKeyManagers(clientCertificateKeyStoreUrl, clientCertificateKeyStoreType, clientCertificateKeyStorePassword); + } + // Create and initialize SSLContext try { - kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kms, tms, null); + return sslContext; } catch (NoSuchAlgorithmException nsae) { - throw new SSLParamsException("Default algorithm definitions for TrustManager and/or KeyManager are invalid. Check java security properties file.", nsae); + throw new SSLParamsException("TLS is not a valid SSL protocol.", nsae); } + catch (KeyManagementException kme) { + throw new SSLParamsException("KeyManagementException: " + kme.getMessage(), kme); + } + } - if (!isNullOrEmpty(clientCertificateKeyStoreUrl)) { - InputStream ksIS = null; - try { - if (!isNullOrEmpty(clientCertificateKeyStoreType)) { - KeyStore clientKeyStore = KeyStore.getInstance(clientCertificateKeyStoreType); - URL ksURL = new URL(clientCertificateKeyStoreUrl); - char[] password = (clientCertificateKeyStorePassword == null) ? new char[0] : clientCertificateKeyStorePassword.toCharArray(); - ksIS = ksURL.openStream(); - clientKeyStore.load(ksIS, password); - kmf.init(clientKeyStore, password); - kms = kmf.getKeyManagers(); - } - } - catch (UnrecoverableKeyException uke) { - throw new SSLParamsException("Could not recover keys from client keystore. Check password?", uke); - } - catch (NoSuchAlgorithmException nsae) { - throw new SSLParamsException("Unsupported keystore algorithm [" + nsae.getMessage() + "]", nsae); - } - catch (KeyStoreException kse) { - throw new SSLParamsException("Could not create KeyStore instance [" + kse.getMessage() + "]", kse); - } - catch (CertificateException nsae) { - throw new SSLParamsException("Could not load client" + clientCertificateKeyStoreType + " keystore from " + clientCertificateKeyStoreUrl, nsae); - } - catch (MalformedURLException mue) { - throw new SSLParamsException(clientCertificateKeyStoreUrl + " does not appear to be a valid URL.", mue); - } - catch (IOException ioe) { - throw new SSLParamsException("Cannot open " + clientCertificateKeyStoreUrl + " [" + ioe.getMessage() + "]", ioe); - } - finally { - if (ksIS != null) { - try { - ksIS.close(); - } - catch (IOException e) { - // can't close input stream, but keystore can be properly initialized so we shouldn't throw this exception - } - } - } + public static SSLContext getValidatingSSLContext(String truststoreUrl, String truststoreType, String truststorePassword, + String keystoreUrl, String keystoreType, String keystorePassword) throws SSLParamsException + { + // Delegate to unified method with validation enabled + TrustManager[] tms; + if (!isNullOrEmpty(truststoreUrl)) { + // SECURE: Load truststore for server certificate validation + tms = loadTrustManagers(truststoreUrl, truststoreType, truststorePassword); + } else { + // No truststore provided - use default + tms = null; + } + // Load KeyManagers (for client certificate authentication) + KeyManager[] kms = null; + if (!isNullOrEmpty(keystoreUrl)) { + kms = loadKeyManagers(keystoreUrl, keystoreType, keystorePassword); } + // Create and initialize SSLContext try { SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(kms, new TrustManager[]{new MyTrustEverybodyManager()}, null); + sslContext.init(kms, tms, null); return sslContext; } catch (NoSuchAlgorithmException nsae) { @@ -81,6 +69,94 @@ public static SSLContext getTrustEverybodySSLContext(String clientCertificateKey } } + private static TrustManager[] loadTrustManagers(String truststoreUrl, String truststoreType, String truststorePassword) + throws SSLParamsException + { + InputStream tsIS = null; + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + // Use provided type or default to JKS if not specified + String storeType = isNullOrEmpty(truststoreType) ? KeyStore.getDefaultType() : truststoreType; + KeyStore trustStore = KeyStore.getInstance(storeType); + URL tsURL = new URL(truststoreUrl); + char[] password = (truststorePassword == null) ? new char[0] : truststorePassword.toCharArray(); + tsIS = tsURL.openStream(); + trustStore.load(tsIS, password); + tmf.init(trustStore); + return tmf.getTrustManagers(); + } + catch (NoSuchAlgorithmException nsae) { + throw new SSLParamsException("Unsupported truststore algorithm [" + nsae.getMessage() + "]", nsae); + } + catch (KeyStoreException kse) { + throw new SSLParamsException("Could not create TrustStore instance [" + kse.getMessage() + "]", kse); + } + catch (CertificateException ce) { + throw new SSLParamsException("Could not load truststore from " + truststoreUrl, ce); + } + catch (MalformedURLException mue) { + throw new SSLParamsException(truststoreUrl + " does not appear to be a valid URL.", mue); + } + catch (IOException ioe) { + throw new SSLParamsException("Cannot open " + truststoreUrl + " [" + ioe.getMessage() + "]", ioe); + } + finally { + if (tsIS != null) { + try { + tsIS.close(); + } + catch (IOException e) { + // can't close input stream, but truststore can be properly initialized so we shouldn't throw this exception + } + } + } + } + + private static KeyManager[] loadKeyManagers(String keystoreUrl, String keystoreType, String keystorePassword) throws SSLParamsException + { + InputStream ksIS = null; + try { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + // Use provided type or default to JKS if not specified + String storeType = isNullOrEmpty(keystoreType) ? KeyStore.getDefaultType() : keystoreType; + KeyStore keyStore = KeyStore.getInstance(storeType); + URL ksURL = new URL(keystoreUrl); + char[] password = (keystorePassword == null) ? new char[0] : keystorePassword.toCharArray(); + ksIS = ksURL.openStream(); + keyStore.load(ksIS, password); + kmf.init(keyStore, password); + return kmf.getKeyManagers(); + } + catch (UnrecoverableKeyException uke) { + throw new SSLParamsException("Could not recover keys from client keystore. Check password?", uke); + } + catch (NoSuchAlgorithmException nsae) { + throw new SSLParamsException("Unsupported keystore algorithm [" + nsae.getMessage() + "]", nsae); + } + catch (KeyStoreException kse) { + throw new SSLParamsException("Could not create KeyStore instance [" + kse.getMessage() + "]", kse); + } + catch (CertificateException ce) { + throw new SSLParamsException("Could not load keystore from " + keystoreUrl, ce); + } + catch (MalformedURLException mue) { + throw new SSLParamsException(keystoreUrl + " does not appear to be a valid URL.", mue); + } + catch (IOException ioe) { + throw new SSLParamsException("Cannot open " + keystoreUrl + " [" + ioe.getMessage() + "]", ioe); + } + finally { + if (ksIS != null) { + try { + ksIS.close(); + } + catch (IOException e) { + // can't close input stream, but keystore can be properly initialized so we shouldn't throw this exception + } + } + } + } + private static class MyTrustEverybodyManager implements X509TrustManager { public void checkClientTrusted(X509Certificate[] x509Certificates, String s) { diff --git a/driver/src/test/jdbc/client/RedisURITest.java b/driver/src/test/jdbc/client/RedisURITest.java index f1d9fda..563dd9d 100644 --- a/driver/src/test/jdbc/client/RedisURITest.java +++ b/driver/src/test/jdbc/client/RedisURITest.java @@ -5,6 +5,10 @@ import org.junit.Test; import redis.clients.jedis.HostAndPort; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.Comparator; @@ -102,4 +106,33 @@ public void testClusterURI() throws SQLException { assertEquals(0, uri.getDatabase()); assertEquals(6, uri.getMaxAttempts()); } + + @Test + public void testSSLWithTruststoreParams() throws SQLException, URISyntaxException { + URL resource = RedisURITest.class.getResource("test-truststore.jks"); + String truststorePath = URLEncoder.encode(resource.toURI().getPath(), StandardCharsets.UTF_8); + RedisJedisURI uri = new RedisJedisURI("jdbc:redis://?ssl=true&truststorePath=" + truststorePath + "&truststorePassword=testpass&truststoreType=JKS", null); + assertTrue(uri.isSsl()); + assertNotNull(uri.getSslSocketFactory()); + } + + @Test + public void testSSLWithKeystoreParams() throws SQLException, URISyntaxException { + URL resource = RedisURITest.class.getResource("test-keystore.jks"); + String keystorePath = URLEncoder.encode(resource.toURI().getPath(), StandardCharsets.UTF_8); + RedisJedisURI uri = new RedisJedisURI("jdbc:redis://?ssl=true&keystorePath=" + keystorePath + "&keystorePassword=testpass&keystoreType=JKS", null); + assertTrue(uri.isSsl()); + assertNotNull(uri.getSslSocketFactory()); + } + + @Test + public void testSSLWithTruststoreAndKeystoreParams() throws SQLException, URISyntaxException { + URL truststoreResource = RedisURITest.class.getResource("test-truststore.jks"); + URL keystoreResource = RedisURITest.class.getResource("test-keystore.jks"); + String truststorePath = URLEncoder.encode(truststoreResource.toURI().getPath(), StandardCharsets.UTF_8); + String keystorePath = URLEncoder.encode(keystoreResource.toURI().getPath(), StandardCharsets.UTF_8); + RedisJedisURI uri = new RedisJedisURI("jdbc:redis://?ssl=true&truststorePath=" + truststorePath + "&truststorePassword=testpass&keystorePath=" + keystorePath + "&keystorePassword=testpass", null); + assertTrue(uri.isSsl()); + assertNotNull(uri.getSslSocketFactory()); + } } diff --git a/driver/src/test/resources/jdbc/client/test-keystore.jks b/driver/src/test/resources/jdbc/client/test-keystore.jks new file mode 100644 index 0000000..e76ae38 Binary files /dev/null and b/driver/src/test/resources/jdbc/client/test-keystore.jks differ diff --git a/driver/src/test/resources/jdbc/client/test-truststore.jks b/driver/src/test/resources/jdbc/client/test-truststore.jks new file mode 100644 index 0000000..fa2f0f1 Binary files /dev/null and b/driver/src/test/resources/jdbc/client/test-truststore.jks differ