Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions client-v2/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@
<version>1.5.7-6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.84</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.clickhouse.client.api.internal;

import com.clickhouse.client.ClickHouseSslContextProvider;
import com.clickhouse.client.api.ClickHouseException;
import com.clickhouse.client.api.Client;
import com.clickhouse.client.api.ClientConfigProperties;
Expand All @@ -14,6 +13,7 @@
import com.clickhouse.client.api.enums.ProxyType;
import com.clickhouse.client.api.http.ClickHouseHttpProto;
import com.clickhouse.client.api.transport.Endpoint;
import com.clickhouse.client.config.ClickHouseDefaultSslContextProvider;
import com.clickhouse.data.ClickHouseFormat;
import net.jpountz.lz4.LZ4Factory;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
Expand Down Expand Up @@ -131,6 +131,8 @@

LZ4Factory lz4Factory;

private final ClickHouseDefaultSslContextProvider sslContextProvider = new ClickHouseDefaultSslContextProvider();

Check warning on line 134 in client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of "ClickHouseDefaultSslContextProvider"; it is deprecated.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ60p-HTpPRyU4bfwrs7&open=AZ60p-HTpPRyU4bfwrs7&pullRequest=2872

Check warning on line 134 in client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of "ClickHouseDefaultSslContextProvider"; it is deprecated.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ60p-HTpPRyU4bfwrs8&open=AZ60p-HTpPRyU4bfwrs8&pullRequest=2872

public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) {
this.metricsRegistry = metricsRegistry;
this.httpClient = createHttpClient(initSslContext, configuration);
Expand Down Expand Up @@ -163,8 +165,10 @@
} catch (NoSuchAlgorithmException e) {
throw new ClientException("Failed to create default SSL context", e);
}
ClickHouseSslContextProvider sslContextProvider = ClickHouseSslContextProvider.getProvider();
String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey());
final String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey());
final String caCertificate = (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey());
final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey());
final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey());
if (trustStorePath != null) {
try {
sslContext = sslContextProvider.getSslContextFromKeyStore(
Expand All @@ -175,16 +179,9 @@
} catch (SSLException e) {
throw new ClientMisconfigurationException("Failed to create SSL context from a keystore", e);
}
} else if (configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()) != null ||
configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()) != null ||
configuration.get(ClientConfigProperties.SSL_KEY.getKey()) != null) {

} else if (caCertificate != null || sslCertificate != null|| sslKey != null) {
try {
sslContext = sslContextProvider.getSslContextFromCerts(
(String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()),
(String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()),
(String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey())
);
sslContext = sslContextProvider.getSslContextFromCerts(sslCertificate, sslKey, caCertificate);
} catch (SSLException e) {
throw new ClientMisconfigurationException("Failed to create SSL context from certificates", e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,50 @@
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.net.URIBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.testcontainers.utility.ThrowingFunction;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.StringWriter;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
Expand Down Expand Up @@ -2145,4 +2174,183 @@
.setDefaultDatabase(ClickHouseServerForTest.getDatabase())
.serverSetting(ServerSettings.WAIT_END_OF_QUERY, "1");
}

private static final String BC_PROVIDER = BouncyCastleProvider.PROVIDER_NAME;

static {
if (Security.getProvider(BC_PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}

@DataProvider(name = "testCustomCaCertificateProvider")
public static Object[][] testCustomCaCertificateProvider() {
return new Object[][]{
// TODO: decide if we need to support certificates via string {true},

Check warning on line 2189 in client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ60p-F9pPRyU4bfwrs6&open=AZ60p-F9pPRyU4bfwrs6&pullRequest=2872
{false}};
}

/**
* End-to-end verification that a client configured with only a custom root CA certificate
* ({@link Client.Builder#setRootCertificate(String)}, no trust store involved) can establish a validated TLS
* connection to a server whose certificate is signed by that CA, and successfully perform operations over it.
* The certificate is passed either as a file or as a string with PEM content. As a control, a client without
* the root CA must fail to validate the same server, proving the provided CA is what makes the trusted
* connection possible.
*/
@Test(groups = {"integration"}, dataProvider = "testCustomCaCertificateProvider")
public void testCustomCaCertificate(boolean certAsString) throws Exception {
if (isCloud()) {
return; // test uses a local WireMock HTTPS server instead of a ClickHouse instance
}

// Generate a private CA and a server certificate (CN/SAN=localhost) signed by it.
KeyPair caKeyPair = generateRsaKeyPair();
X500Name caSubject = new X500Name("CN=ClickHouse Java Test CA");
X509Certificate caCertificate = generateCertificate(caSubject, caSubject, caKeyPair.getPublic(),
caKeyPair.getPrivate(), caKeyPair.getPublic(), true, null);

KeyPair serverKeyPair = generateRsaKeyPair();
X500Name serverSubject = new X500Name("CN=localhost");
GeneralNames serverSans = new GeneralNames(new GeneralName[]{
new GeneralName(GeneralName.dNSName, "localhost"),
new GeneralName(GeneralName.iPAddress, "127.0.0.1")
});
X509Certificate serverCertificate = generateCertificate(serverSubject, caSubject, serverKeyPair.getPublic(),
caKeyPair.getPrivate(), caKeyPair.getPublic(), false, serverSans);

// Server side: PKCS12 keystore with the server key and its certificate chain for WireMock HTTPS.
char[] keystorePassword = "changeit".toCharArray();
KeyStore serverKeyStore = KeyStore.getInstance("PKCS12");
serverKeyStore.load(null, null);
serverKeyStore.setKeyEntry("server", serverKeyPair.getPrivate(), keystorePassword,
new Certificate[]{serverCertificate, caCertificate});
Path keyStoreFile = Files.createTempFile("ch-test-server-keystore-", ".p12");
try (java.io.OutputStream out = Files.newOutputStream(keyStoreFile)) {
serverKeyStore.store(out, keystorePassword);
}

// Client side: only the root CA certificate, either as PEM content or as a file.
String caCertificatePem = toPem(caCertificate);
Path caCertFile = Files.createTempFile("ch-test-ca-", ".crt");
Files.write(caCertFile, caCertificatePem.getBytes(StandardCharsets.US_ASCII));
String rootCertificateValue = certAsString ? caCertificatePem : caCertFile.toAbsolutePath().toString();

WireMockServer httpsServer = new WireMockServer(WireMockConfiguration.options()
.dynamicPort()
.dynamicHttpsPort()
.keystorePath(keyStoreFile.toAbsolutePath().toString())
.keystorePassword(new String(keystorePassword))
.keyManagerPassword(new String(keystorePassword))
.keystoreType("PKCS12")
.notifier(new ConsoleNotifier(false)));
httpsServer.start();

try {
// ClickHouse-style success response for any query/command.
httpsServer.addStubMapping(WireMock.post(WireMock.anyUrl())
.willReturn(WireMock.aResponse()
.withStatus(HttpStatus.SC_OK)
.withHeader(ClickHouseHttpProto.HEADER_QUERY_ID, UUID.randomUUID().toString())
.withBody("1\n"))
.build());

int httpsPort = httpsServer.httpsPort();
String endpoint = "https://localhost:" + httpsPort;

// Trusting client: configured with the root CA certificate only.
try (Client client = new Client.Builder()
.addEndpoint(endpoint)
.setUsername("default")
.setPassword("")
.setRootCertificate(rootCertificateValue)
.compressClientRequest(false)
.compressServerResponse(false)
.build()) {

// Ping executes "SELECT 1" - succeeds only if the TLS handshake validated the server certificate.
Assert.assertTrue(client.ping(),
"Client should validate the server certificate using the root CA passed as "
+ (certAsString ? "a string" : "a file"));

// A real operation over the validated connection: read the result back.
try (QueryResponse response =
client.query("SELECT 1 FORMAT TabSeparated").get(10, TimeUnit.SECONDS)) {
Assert.assertNotNull(response.getQueryId());
ByteArrayOutputStream content = new ByteArrayOutputStream();
byte[] buffer = new byte[128];
int read;
while ((read = response.getInputStream().read(buffer)) != -1) {
content.write(buffer, 0, read);
}
Assert.assertEquals(content.toString("UTF-8"), "1\n");
}
}

// The operations actually reached the HTTPS mock server.
httpsServer.verify(WireMock.moreThanOrExactly(1), WireMock.postRequestedFor(WireMock.anyUrl()));

// Control client: without the root CA the default trust store rejects the private chain.
try (Client client = new Client.Builder()
.addEndpoint(endpoint)
.setUsername("default")
.setPassword("")
.compressClientRequest(false)
.compressServerResponse(false)
.build()) {
Assert.assertFalse(client.ping(),
"Client should not trust the server when the root CA is not provided");
}
} finally {
httpsServer.stop();
Files.deleteIfExists(keyStoreFile);
Files.deleteIfExists(caCertFile);
}
}

private static KeyPair generateRsaKeyPair() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048, new SecureRandom());
return keyPairGenerator.generateKeyPair();
}

private static X509Certificate generateCertificate(X500Name subject, X500Name issuer, PublicKey subjectPublicKey,
PrivateKey issuerPrivateKey, PublicKey issuerPublicKey,
boolean isCa, GeneralNames subjectAlternativeNames)
throws Exception {
Date notBefore = new Date(System.currentTimeMillis() - 60_000L);
Date notAfter = new Date(System.currentTimeMillis() + Duration.ofDays(1).toMillis());
BigInteger serial = new BigInteger(160, new SecureRandom()).abs().add(BigInteger.ONE);

X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
issuer, serial, notBefore, notAfter, subject, subjectPublicKey);
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa));
certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(isCa
? KeyUsage.keyCertSign | KeyUsage.cRLSign
: KeyUsage.digitalSignature | KeyUsage.keyEncipherment));

JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils();
certBuilder.addExtension(Extension.subjectKeyIdentifier, false,
extensionUtils.createSubjectKeyIdentifier(subjectPublicKey));
certBuilder.addExtension(Extension.authorityKeyIdentifier, false,
extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey));
if (subjectAlternativeNames != null) {
certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames);
}

ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA")
.setProvider(BC_PROVIDER)
.build(issuerPrivateKey);
return new JcaX509CertificateConverter()
.setProvider(BC_PROVIDER)
.getCertificate(certBuilder.build(signer));
}

private static String toPem(X509Certificate certificate) throws Exception {
StringWriter stringWriter = new StringWriter();
try (JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter)) {
pemWriter.writeObject(certificate);
}
return stringWriter.toString();
}
}
Loading
Loading