Skip to content

Commit 8353658

Browse files
authored
Merge pull request #2872 from ClickHouse/06/10/26/trusted_root_cert
SSL Root Certificate Examples
2 parents 5188333 + 4ac5a16 commit 8353658

12 files changed

Lines changed: 1258 additions & 14 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,8 @@ performance/jmh-simple-results.json
6060
*.csv
6161
*.sql
6262
*.json
63+
64+
*.crt
65+
*.key
66+
*.srl
67+
*.csr

client-v2/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@
147147
<version>1.5.7-6</version>
148148
<scope>test</scope>
149149
</dependency>
150+
<dependency>
151+
<groupId>org.bouncycastle</groupId>
152+
<artifactId>bcprov-jdk18on</artifactId>
153+
<version>1.84</version>
154+
<scope>test</scope>
155+
</dependency>
156+
<dependency>
157+
<groupId>org.bouncycastle</groupId>
158+
<artifactId>bcpkix-jdk18on</artifactId>
159+
<version>1.84</version>
160+
<scope>test</scope>
161+
</dependency>
150162
</dependencies>
151163

152164
<build>

client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.clickhouse.client.api.internal;
22

3-
import com.clickhouse.client.ClickHouseSslContextProvider;
43
import com.clickhouse.client.api.ClickHouseException;
54
import com.clickhouse.client.api.Client;
65
import com.clickhouse.client.api.ClientConfigProperties;
@@ -14,6 +13,7 @@
1413
import com.clickhouse.client.api.enums.ProxyType;
1514
import com.clickhouse.client.api.http.ClickHouseHttpProto;
1615
import com.clickhouse.client.api.transport.Endpoint;
16+
import com.clickhouse.client.config.ClickHouseDefaultSslContextProvider;
1717
import com.clickhouse.data.ClickHouseFormat;
1818
import net.jpountz.lz4.LZ4Factory;
1919
import org.apache.commons.compress.compressors.CompressorStreamFactory;
@@ -131,6 +131,8 @@ public class HttpAPIClientHelper {
131131

132132
LZ4Factory lz4Factory;
133133

134+
private final ClickHouseDefaultSslContextProvider sslContextProvider = new ClickHouseDefaultSslContextProvider();
135+
134136
public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) {
135137
this.metricsRegistry = metricsRegistry;
136138
this.httpClient = createHttpClient(initSslContext, configuration);
@@ -163,8 +165,10 @@ public SSLContext createSSLContext(Map<String, Object> configuration) {
163165
} catch (NoSuchAlgorithmException e) {
164166
throw new ClientException("Failed to create default SSL context", e);
165167
}
166-
ClickHouseSslContextProvider sslContextProvider = ClickHouseSslContextProvider.getProvider();
167-
String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey());
168+
final String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey());
169+
final String caCertificate = (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey());
170+
final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey());
171+
final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey());
168172
if (trustStorePath != null) {
169173
try {
170174
sslContext = sslContextProvider.getSslContextFromKeyStore(
@@ -175,16 +179,9 @@ public SSLContext createSSLContext(Map<String, Object> configuration) {
175179
} catch (SSLException e) {
176180
throw new ClientMisconfigurationException("Failed to create SSL context from a keystore", e);
177181
}
178-
} else if (configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()) != null ||
179-
configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()) != null ||
180-
configuration.get(ClientConfigProperties.SSL_KEY.getKey()) != null) {
181-
182+
} else if (caCertificate != null || sslCertificate != null|| sslKey != null) {
182183
try {
183-
sslContext = sslContextProvider.getSslContextFromCerts(
184-
(String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()),
185-
(String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()),
186-
(String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey())
187-
);
184+
sslContext = sslContextProvider.getSslContextFromCerts(sslCertificate, sslKey, caCertificate);
188185
} catch (SSLException e) {
189186
throw new ClientMisconfigurationException("Failed to create SSL context from certificates", e);
190187
}

client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,50 @@
3838
import org.apache.hc.core5.http.HttpHeaders;
3939
import org.apache.hc.core5.http.HttpStatus;
4040
import org.apache.hc.core5.net.URIBuilder;
41+
import org.bouncycastle.asn1.x500.X500Name;
42+
import org.bouncycastle.asn1.x509.BasicConstraints;
43+
import org.bouncycastle.asn1.x509.Extension;
44+
import org.bouncycastle.asn1.x509.GeneralName;
45+
import org.bouncycastle.asn1.x509.GeneralNames;
46+
import org.bouncycastle.asn1.x509.KeyUsage;
47+
import org.bouncycastle.cert.X509v3CertificateBuilder;
48+
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
49+
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
50+
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
51+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
52+
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
53+
import org.bouncycastle.operator.ContentSigner;
54+
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
4155
import org.testcontainers.utility.ThrowingFunction;
4256
import org.testng.Assert;
4357
import org.testng.annotations.DataProvider;
4458
import org.testng.annotations.Test;
4559

4660
import java.io.ByteArrayInputStream;
4761
import java.io.ByteArrayOutputStream;
62+
import java.io.StringWriter;
63+
import java.math.BigInteger;
4864
import java.net.InetAddress;
4965
import java.net.Socket;
5066
import java.nio.ByteBuffer;
5167
import java.nio.charset.StandardCharsets;
68+
import java.nio.file.Files;
69+
import java.nio.file.Path;
70+
import java.security.KeyPair;
71+
import java.security.KeyPairGenerator;
72+
import java.security.KeyStore;
73+
import java.security.PrivateKey;
74+
import java.security.PublicKey;
75+
import java.security.SecureRandom;
76+
import java.security.Security;
77+
import java.security.cert.Certificate;
78+
import java.security.cert.X509Certificate;
79+
import java.time.Duration;
5280
import java.time.temporal.ChronoUnit;
5381
import java.util.ArrayList;
5482
import java.util.Arrays;
5583
import java.util.Base64;
84+
import java.util.Date;
5685
import java.util.EnumSet;
5786
import java.util.HashMap;
5887
import java.util.HashSet;
@@ -2145,4 +2174,183 @@ protected Client.Builder newClient() {
21452174
.setDefaultDatabase(ClickHouseServerForTest.getDatabase())
21462175
.serverSetting(ServerSettings.WAIT_END_OF_QUERY, "1");
21472176
}
2177+
2178+
private static final String BC_PROVIDER = BouncyCastleProvider.PROVIDER_NAME;
2179+
2180+
static {
2181+
if (Security.getProvider(BC_PROVIDER) == null) {
2182+
Security.addProvider(new BouncyCastleProvider());
2183+
}
2184+
}
2185+
2186+
@DataProvider(name = "testCustomCaCertificateProvider")
2187+
public static Object[][] testCustomCaCertificateProvider() {
2188+
return new Object[][]{
2189+
// TODO: decide if we need to support certificates via string {true},
2190+
{false}};
2191+
}
2192+
2193+
/**
2194+
* End-to-end verification that a client configured with only a custom root CA certificate
2195+
* ({@link Client.Builder#setRootCertificate(String)}, no trust store involved) can establish a validated TLS
2196+
* connection to a server whose certificate is signed by that CA, and successfully perform operations over it.
2197+
* The certificate is passed either as a file or as a string with PEM content. As a control, a client without
2198+
* the root CA must fail to validate the same server, proving the provided CA is what makes the trusted
2199+
* connection possible.
2200+
*/
2201+
@Test(groups = {"integration"}, dataProvider = "testCustomCaCertificateProvider")
2202+
public void testCustomCaCertificate(boolean certAsString) throws Exception {
2203+
if (isCloud()) {
2204+
return; // test uses a local WireMock HTTPS server instead of a ClickHouse instance
2205+
}
2206+
2207+
// Generate a private CA and a server certificate (CN/SAN=localhost) signed by it.
2208+
KeyPair caKeyPair = generateRsaKeyPair();
2209+
X500Name caSubject = new X500Name("CN=ClickHouse Java Test CA");
2210+
X509Certificate caCertificate = generateCertificate(caSubject, caSubject, caKeyPair.getPublic(),
2211+
caKeyPair.getPrivate(), caKeyPair.getPublic(), true, null);
2212+
2213+
KeyPair serverKeyPair = generateRsaKeyPair();
2214+
X500Name serverSubject = new X500Name("CN=localhost");
2215+
GeneralNames serverSans = new GeneralNames(new GeneralName[]{
2216+
new GeneralName(GeneralName.dNSName, "localhost"),
2217+
new GeneralName(GeneralName.iPAddress, "127.0.0.1")
2218+
});
2219+
X509Certificate serverCertificate = generateCertificate(serverSubject, caSubject, serverKeyPair.getPublic(),
2220+
caKeyPair.getPrivate(), caKeyPair.getPublic(), false, serverSans);
2221+
2222+
// Server side: PKCS12 keystore with the server key and its certificate chain for WireMock HTTPS.
2223+
char[] keystorePassword = "changeit".toCharArray();
2224+
KeyStore serverKeyStore = KeyStore.getInstance("PKCS12");
2225+
serverKeyStore.load(null, null);
2226+
serverKeyStore.setKeyEntry("server", serverKeyPair.getPrivate(), keystorePassword,
2227+
new Certificate[]{serverCertificate, caCertificate});
2228+
Path keyStoreFile = Files.createTempFile("ch-test-server-keystore-", ".p12");
2229+
try (java.io.OutputStream out = Files.newOutputStream(keyStoreFile)) {
2230+
serverKeyStore.store(out, keystorePassword);
2231+
}
2232+
2233+
// Client side: only the root CA certificate, either as PEM content or as a file.
2234+
String caCertificatePem = toPem(caCertificate);
2235+
Path caCertFile = Files.createTempFile("ch-test-ca-", ".crt");
2236+
Files.write(caCertFile, caCertificatePem.getBytes(StandardCharsets.US_ASCII));
2237+
String rootCertificateValue = certAsString ? caCertificatePem : caCertFile.toAbsolutePath().toString();
2238+
2239+
WireMockServer httpsServer = new WireMockServer(WireMockConfiguration.options()
2240+
.dynamicPort()
2241+
.dynamicHttpsPort()
2242+
.keystorePath(keyStoreFile.toAbsolutePath().toString())
2243+
.keystorePassword(new String(keystorePassword))
2244+
.keyManagerPassword(new String(keystorePassword))
2245+
.keystoreType("PKCS12")
2246+
.notifier(new ConsoleNotifier(false)));
2247+
httpsServer.start();
2248+
2249+
try {
2250+
// ClickHouse-style success response for any query/command.
2251+
httpsServer.addStubMapping(WireMock.post(WireMock.anyUrl())
2252+
.willReturn(WireMock.aResponse()
2253+
.withStatus(HttpStatus.SC_OK)
2254+
.withHeader(ClickHouseHttpProto.HEADER_QUERY_ID, UUID.randomUUID().toString())
2255+
.withBody("1\n"))
2256+
.build());
2257+
2258+
int httpsPort = httpsServer.httpsPort();
2259+
String endpoint = "https://localhost:" + httpsPort;
2260+
2261+
// Trusting client: configured with the root CA certificate only.
2262+
try (Client client = new Client.Builder()
2263+
.addEndpoint(endpoint)
2264+
.setUsername("default")
2265+
.setPassword("")
2266+
.setRootCertificate(rootCertificateValue)
2267+
.compressClientRequest(false)
2268+
.compressServerResponse(false)
2269+
.build()) {
2270+
2271+
// Ping executes "SELECT 1" - succeeds only if the TLS handshake validated the server certificate.
2272+
Assert.assertTrue(client.ping(),
2273+
"Client should validate the server certificate using the root CA passed as "
2274+
+ (certAsString ? "a string" : "a file"));
2275+
2276+
// A real operation over the validated connection: read the result back.
2277+
try (QueryResponse response =
2278+
client.query("SELECT 1 FORMAT TabSeparated").get(10, TimeUnit.SECONDS)) {
2279+
Assert.assertNotNull(response.getQueryId());
2280+
ByteArrayOutputStream content = new ByteArrayOutputStream();
2281+
byte[] buffer = new byte[128];
2282+
int read;
2283+
while ((read = response.getInputStream().read(buffer)) != -1) {
2284+
content.write(buffer, 0, read);
2285+
}
2286+
Assert.assertEquals(content.toString("UTF-8"), "1\n");
2287+
}
2288+
}
2289+
2290+
// The operations actually reached the HTTPS mock server.
2291+
httpsServer.verify(WireMock.moreThanOrExactly(1), WireMock.postRequestedFor(WireMock.anyUrl()));
2292+
2293+
// Control client: without the root CA the default trust store rejects the private chain.
2294+
try (Client client = new Client.Builder()
2295+
.addEndpoint(endpoint)
2296+
.setUsername("default")
2297+
.setPassword("")
2298+
.compressClientRequest(false)
2299+
.compressServerResponse(false)
2300+
.build()) {
2301+
Assert.assertFalse(client.ping(),
2302+
"Client should not trust the server when the root CA is not provided");
2303+
}
2304+
} finally {
2305+
httpsServer.stop();
2306+
Files.deleteIfExists(keyStoreFile);
2307+
Files.deleteIfExists(caCertFile);
2308+
}
2309+
}
2310+
2311+
private static KeyPair generateRsaKeyPair() throws Exception {
2312+
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
2313+
keyPairGenerator.initialize(2048, new SecureRandom());
2314+
return keyPairGenerator.generateKeyPair();
2315+
}
2316+
2317+
private static X509Certificate generateCertificate(X500Name subject, X500Name issuer, PublicKey subjectPublicKey,
2318+
PrivateKey issuerPrivateKey, PublicKey issuerPublicKey,
2319+
boolean isCa, GeneralNames subjectAlternativeNames)
2320+
throws Exception {
2321+
Date notBefore = new Date(System.currentTimeMillis() - 60_000L);
2322+
Date notAfter = new Date(System.currentTimeMillis() + Duration.ofDays(1).toMillis());
2323+
BigInteger serial = new BigInteger(160, new SecureRandom()).abs().add(BigInteger.ONE);
2324+
2325+
X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
2326+
issuer, serial, notBefore, notAfter, subject, subjectPublicKey);
2327+
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa));
2328+
certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(isCa
2329+
? KeyUsage.keyCertSign | KeyUsage.cRLSign
2330+
: KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
2331+
2332+
JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils();
2333+
certBuilder.addExtension(Extension.subjectKeyIdentifier, false,
2334+
extensionUtils.createSubjectKeyIdentifier(subjectPublicKey));
2335+
certBuilder.addExtension(Extension.authorityKeyIdentifier, false,
2336+
extensionUtils.createAuthorityKeyIdentifier(issuerPublicKey));
2337+
if (subjectAlternativeNames != null) {
2338+
certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAlternativeNames);
2339+
}
2340+
2341+
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA")
2342+
.setProvider(BC_PROVIDER)
2343+
.build(issuerPrivateKey);
2344+
return new JcaX509CertificateConverter()
2345+
.setProvider(BC_PROVIDER)
2346+
.getCertificate(certBuilder.build(signer));
2347+
}
2348+
2349+
private static String toPem(X509Certificate certificate) throws Exception {
2350+
StringWriter stringWriter = new StringWriter();
2351+
try (JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter)) {
2352+
pemWriter.writeObject(certificate);
2353+
}
2354+
return stringWriter.toString();
2355+
}
21482356
}

0 commit comments

Comments
 (0)