From f2ac4b079320d7dac58321a717d79daf5bc4a56d Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 10 Jun 2026 21:11:04 -0700 Subject: [PATCH 1/3] implemented accepting certificates as plain strings --- .../ClickHouseDefaultSslContextProvider.java | 52 ++++++++++++----- ...ickHouseDefaultSslContextProviderTest.java | 58 +++++++++++++++++++ .../clickhouse/client/HttpTransportTests.java | 2 +- .../examples/client_v2/SSLExamples.java | 57 +++++++++++++++++- .../clickhouse/examples/jdbc/SSLExamples.java | 50 +++++++++++++++- 5 files changed, 201 insertions(+), 18 deletions(-) diff --git a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java index 5bb432e6f..cf114cb26 100644 --- a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java +++ b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java @@ -1,6 +1,22 @@ package com.clickhouse.client.config; -import java.io.*; +import com.clickhouse.client.ClickHouseConfig; +import com.clickhouse.client.ClickHouseSslContextProvider; +import com.clickhouse.data.ClickHouseUtils; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.KeyManagementException; import java.security.KeyStore; @@ -18,24 +34,30 @@ import java.util.Base64; import java.util.Optional; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - -import com.clickhouse.client.ClickHouseConfig; -import com.clickhouse.client.ClickHouseSslContextProvider; -import com.clickhouse.data.ClickHouseUtils; - @Deprecated public class ClickHouseDefaultSslContextProvider implements ClickHouseSslContextProvider { static final String PEM_HEADER_PREFIX = "---BEGIN "; static final String PEM_HEADER_SUFFIX = " PRIVATE KEY---"; static final String PEM_FOOTER_PREFIX = "---END "; + /** Standard PEM encapsulation boundary (RFC 7468). Present in any PEM content, never in a file path. */ + static final String PEM_BEGIN_MARKER = "-----BEGIN"; + + /** + * Opens a stream over PEM material that may be supplied either as a file path (also searched in the home + * directory and on the classpath) or directly as PEM content. + * + * @param certOrContent file path or PEM content of a certificate or a private key + * @return stream over the PEM content + * @throws IOException when the value is a path and the file cannot be opened + */ + static InputStream getCertificateInputStream(String certOrContent) throws IOException { + if (certOrContent.contains(PEM_BEGIN_MARKER)) { + return new ByteArrayInputStream(certOrContent.getBytes(StandardCharsets.US_ASCII)); + } + return ClickHouseUtils.getFileInputStream(certOrContent); + } + /** * An insecure {@link javax.net.ssl.TrustManager}, that don't validate the * certificate. @@ -71,7 +93,7 @@ public static PrivateKey getPrivateKey(String keyFile) String algorithm = (String) ClickHouseDefaults.SSL_KEY_ALGORITHM.getEffectiveDefaultValue(); StringBuilder builder = new StringBuilder(); try (BufferedReader reader = new BufferedReader( - new InputStreamReader(ClickHouseUtils.getFileInputStream(keyFile)))) { + new InputStreamReader(getCertificateInputStream(keyFile)))) { String line = reader.readLine(); if (line != null) { algorithm = getAlgorithm(line, algorithm); @@ -102,7 +124,7 @@ public KeyStore getKeyStore(String cert, String key) throws NoSuchAlgorithmExcep ClickHouseUtils.format("%s KeyStore not available", KeyStore.getDefaultType())); } - try (InputStream in = ClickHouseUtils.getFileInputStream(cert)) { + try (InputStream in = getCertificateInputStream(cert)) { CertificateFactory factory = CertificateFactory .getInstance((String) ClickHouseDefaults.SSL_CERTIFICATE_TYPE.getEffectiveDefaultValue()); if (key == null || key.isEmpty()) { diff --git a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java index 2fe066a48..a0baec5d9 100644 --- a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java +++ b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java @@ -1,9 +1,30 @@ package com.clickhouse.client.config; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.PrivateKey; + +import com.clickhouse.data.ClickHouseUtils; + import org.testng.Assert; import org.testng.annotations.Test; public class ClickHouseDefaultSslContextProviderTest { + static String readTestResource(String name) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (InputStream in = ClickHouseUtils.getFileInputStream(name)) { + byte[] buffer = new byte[2048]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + return new String(out.toByteArray(), StandardCharsets.US_ASCII); + } + @Test(groups = { "unit" }) public void testGetAlgorithm() { Assert.assertEquals(ClickHouseDefaultSslContextProvider.getAlgorithm("", null), null); @@ -19,4 +40,41 @@ public void testGetPrivateKey() throws Exception { // openssl genpkey -out pkey4test.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048 Assert.assertNotNull(ClickHouseDefaultSslContextProvider.getPrivateKey("pkey4test.pem")); } + + @Test(groups = { "unit" }) + public void testGetCertificateInputStream() throws Exception { + String pemContent = readTestResource("client.crt"); + try (InputStream in = ClickHouseDefaultSslContextProvider.getCertificateInputStream(pemContent)) { + byte[] buffer = new byte[pemContent.length()]; + int read = in.read(buffer); + Assert.assertEquals(new String(buffer, 0, read, StandardCharsets.US_ASCII), pemContent); + } + + try (InputStream in = ClickHouseDefaultSslContextProvider.getCertificateInputStream("client.crt")) { + Assert.assertTrue(in.read() != -1); + } + + Assert.assertThrows(FileNotFoundException.class, + () -> ClickHouseDefaultSslContextProvider.getCertificateInputStream("non-existent.crt")); + } + + @Test(groups = { "unit" }) + public void testGetPrivateKeyFromPemContent() throws Exception { + PrivateKey fromFile = ClickHouseDefaultSslContextProvider.getPrivateKey("pkey4test.pem"); + PrivateKey fromContent = ClickHouseDefaultSslContextProvider + .getPrivateKey(readTestResource("pkey4test.pem")); + Assert.assertEquals(fromContent, fromFile); + } + + @Test(groups = { "unit" }) + public void testGetKeyStoreFromPemContent() throws Exception { + ClickHouseDefaultSslContextProvider provider = new ClickHouseDefaultSslContextProvider(); + + KeyStore trustStore = provider.getKeyStore(readTestResource("client.crt"), null); + Assert.assertNotNull(trustStore.getCertificate("cert1")); + + KeyStore keyStore = provider.getKeyStore(readTestResource("some_user.crt"), + readTestResource("some_user.key")); + Assert.assertNotNull(keyStore.getKey("key", null)); + } } \ No newline at end of file diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 0e4483d13..5c6384a60 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -2186,7 +2186,7 @@ protected Client.Builder newClient() { @DataProvider(name = "testCustomCaCertificateProvider") public static Object[][] testCustomCaCertificateProvider() { return new Object[][]{ - // TODO: decide if we need to support certificates via string {true}, + {true}, {false}}; } diff --git a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java index 3cefc29ee..5d47235f4 100644 --- a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java +++ b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java @@ -4,6 +4,10 @@ import com.clickhouse.client.api.query.GenericRecord; import lombok.extern.slf4j.Slf4j; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.List; /** @@ -15,6 +19,9 @@ * the CA certificate is passed with {@link Client.Builder#setRootCertificate(String)}. * No trust store configuration is needed: the certificate is added to a trust store * used only by this client, so the JVM default trust store stays untouched. + *
  • Passing the CA certificate as a PEM string instead of a file path - useful when the + * certificate comes from an environment variable or a secret manager (typical for + * Kubernetes/cloud deployments) and you do not want to write it to disk.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -58,7 +65,9 @@ public static void main(String[] args) { } log.info("Running in standalone mode against {}:{}", host, port); - connectWithCustomRootCertificate("https://" + host + ":" + port, database, user, password, rootCert); + String endpoint = "https://" + host + ":" + port; + connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); + connectWithRootCertificateAsString(endpoint, database, user, password, rootCert); return; } @@ -69,6 +78,8 @@ public static void main(String[] args) { try (SecureServerSupport server = SecureServerSupport.start(image)) { connectWithCustomRootCertificate(server.getEndpoint(), database, SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithRootCertificateAsString(server.getEndpoint(), database, + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); } catch (Exception e) { log.error("Failed to run the SSL example against a local Docker server", e); Runtime.getRuntime().exit(-1); @@ -103,6 +114,50 @@ static void connectWithCustomRootCertificate(String endpoint, String database, S } } + /** + * Same as {@link #connectWithCustomRootCertificate}, but the CA certificate is passed as PEM + * content instead of a file path. {@link Client.Builder#setRootCertificate(String)} accepts both: + * any value containing a {@code -----BEGIN ...-----} block is treated as PEM content. + * + *

    This is handy when the certificate is delivered through an environment variable or + * a secret manager (e.g. a Kubernetes secret projected into {@code CLICKHOUSE_CA_CERT}), + * so the application never has to write it to disk:

    + * + *
    {@code
    +     * String caPem = System.getenv("CLICKHOUSE_CA_CERT");
    +     * Client client = new Client.Builder().setRootCertificate(caPem)...
    +     * }
    + */ + static void connectWithRootCertificateAsString(String endpoint, String database, String user, String password, + String rootCertPath) { + final String rootCertPem; + try { + // In a real application the PEM content would typically come from an env variable + // or a secret manager; here we simply read the file generated for this example. + rootCertPem = new String(Files.readAllBytes(Paths.get(rootCertPath)), StandardCharsets.US_ASCII); + } catch (IOException e) { + log.error("Failed to read the CA certificate from {}", rootCertPath, e); + return; + } + + log.info("Connecting to {} using root CA certificate passed as a PEM string", endpoint); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + // PEM content, not a path - detected by the "-----BEGIN" marker. + .setRootCertificate(rootCertPem) + .build()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected securely (CA cert as string) as '{}' to ClickHouse {}", + rows.get(0).getString("user"), rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Secure connection with a CA certificate passed as a string failed", e); + } + } + private static String trimToNull(String value) { if (value == null) { return null; diff --git a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java index c03253650..11a022fb5 100644 --- a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java +++ b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java @@ -4,6 +4,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; @@ -20,6 +24,9 @@ * the CA certificate is passed with the {@code sslrootcert} connection property. * No trust store configuration is needed: the certificate is added to a trust store * used only by this connection, so the JVM default trust store stays untouched. + *
  • Passing the CA certificate as a PEM string instead of a file path - useful when the + * certificate comes from an environment variable or a secret manager (typical for + * Kubernetes/cloud deployments) and you do not want to write it to disk.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -62,7 +69,8 @@ public static void main(String[] args) { log.info("Running in standalone mode against {}", url); try { connectWithCustomRootCertificate(url, user, password, rootCert); - } catch (SQLException e) { + connectWithRootCertificateAsString(url, user, password, rootCert); + } catch (SQLException | IOException e) { log.error("Secure connection with a custom root CA certificate failed", e); } return; @@ -75,6 +83,8 @@ public static void main(String[] args) { try (SecureServerSupport server = SecureServerSupport.start(image)) { connectWithCustomRootCertificate(server.getJdbcUrl(), SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithRootCertificateAsString(server.getJdbcUrl(), + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); } catch (Exception e) { log.error("Failed to run the SSL example against a local Docker server", e); Runtime.getRuntime().exit(-1); @@ -109,6 +119,44 @@ static void connectWithCustomRootCertificate(String url, String user, String pas } } + /** + * Same as {@link #connectWithCustomRootCertificate}, but the CA certificate is passed as PEM + * content instead of a file path. The {@code sslrootcert} property accepts both: any value + * containing a {@code -----BEGIN ...-----} block is treated as PEM content. + * + *

    This is handy when the certificate is delivered through an environment variable or + * a secret manager (e.g. a Kubernetes secret projected into {@code CLICKHOUSE_CA_CERT}), + * so the application never has to write it to disk:

    + * + *
    {@code
    +     * properties.setProperty("sslrootcert", System.getenv("CLICKHOUSE_CA_CERT"));
    +     * }
    + */ + static void connectWithRootCertificateAsString(String url, String user, String password, String rootCertPath) + throws SQLException, IOException { + // In a real application the PEM content would typically come from an env variable + // or a secret manager; here we simply read the file generated for this example. + String rootCertPem = new String(Files.readAllBytes(Paths.get(rootCertPath)), StandardCharsets.US_ASCII); + + log.info("Connecting to {} using root CA certificate passed as a PEM string", url); + + Properties properties = new Properties(); + properties.setProperty(ClientConfigProperties.USER.getKey(), user); // user + properties.setProperty(ClientConfigProperties.PASSWORD.getKey(), password); // password + properties.setProperty("ssl", "true"); // enable TLS even if the URL has no https scheme + // PEM content, not a path - detected by the "-----BEGIN" marker. + properties.setProperty(ClientConfigProperties.CA_CERTIFICATE.getKey(), rootCertPem); // sslrootcert + + try (Connection connection = DriverManager.getConnection(url, properties); + Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT currentUser() AS user, version() AS version")) { + if (rs.next()) { + log.info("Connected securely (CA cert as string) as '{}' to ClickHouse {}", + rs.getString("user"), rs.getString("version")); + } + } + } + private static String trimToNull(String value) { if (value == null) { return null; From 2516d015ce7ea4bc27778b9e691c30d2d3adf048 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Mon, 15 Jun 2026 09:58:39 -0700 Subject: [PATCH 2/3] Fix tests a bit --- ...lickHouseDefaultSslContextProviderTest.java | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java index a0baec5d9..5c362dee7 100644 --- a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java +++ b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java @@ -1,28 +1,20 @@ package com.clickhouse.client.config; -import java.io.ByteArrayOutputStream; +import com.clickhouse.data.ClickHouseUtils; +import org.testng.Assert; +import org.testng.annotations.Test; + import java.io.FileNotFoundException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.PrivateKey; -import com.clickhouse.data.ClickHouseUtils; - -import org.testng.Assert; -import org.testng.annotations.Test; - public class ClickHouseDefaultSslContextProviderTest { static String readTestResource(String name) throws Exception { - ByteArrayOutputStream out = new ByteArrayOutputStream(); try (InputStream in = ClickHouseUtils.getFileInputStream(name)) { - byte[] buffer = new byte[2048]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } + return new String(in.readAllBytes(), StandardCharsets.US_ASCII); } - return new String(out.toByteArray(), StandardCharsets.US_ASCII); } @Test(groups = { "unit" }) From d49e2741e54db98e2a3825bb44b4b2c781e376fd Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Mon, 15 Jun 2026 11:33:02 -0700 Subject: [PATCH 3/3] Added semantic check in SSL context tests --- .../client/config/ClickHouseDefaultSslContextProviderTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java index 5c362dee7..c07132ca7 100644 --- a/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java +++ b/clickhouse-client/src/test/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProviderTest.java @@ -56,6 +56,9 @@ public void testGetPrivateKeyFromPemContent() throws Exception { PrivateKey fromContent = ClickHouseDefaultSslContextProvider .getPrivateKey(readTestResource("pkey4test.pem")); Assert.assertEquals(fromContent, fromFile); + Assert.assertEquals(fromContent.getAlgorithm(), fromFile.getAlgorithm()); + Assert.assertEquals(fromContent.getFormat(), fromFile.getFormat()); + Assert.assertEquals(fromContent.getEncoded(), fromFile.getEncoded()); } @Test(groups = { "unit" })