|
38 | 38 | import org.apache.hc.core5.http.HttpHeaders; |
39 | 39 | import org.apache.hc.core5.http.HttpStatus; |
40 | 40 | 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; |
41 | 55 | import org.testcontainers.utility.ThrowingFunction; |
42 | 56 | import org.testng.Assert; |
43 | 57 | import org.testng.annotations.DataProvider; |
44 | 58 | import org.testng.annotations.Test; |
45 | 59 |
|
46 | 60 | import java.io.ByteArrayInputStream; |
47 | 61 | import java.io.ByteArrayOutputStream; |
| 62 | +import java.io.StringWriter; |
| 63 | +import java.math.BigInteger; |
48 | 64 | import java.net.InetAddress; |
49 | 65 | import java.net.Socket; |
50 | 66 | import java.nio.ByteBuffer; |
51 | 67 | 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; |
52 | 80 | import java.time.temporal.ChronoUnit; |
53 | 81 | import java.util.ArrayList; |
54 | 82 | import java.util.Arrays; |
55 | 83 | import java.util.Base64; |
| 84 | +import java.util.Date; |
56 | 85 | import java.util.EnumSet; |
57 | 86 | import java.util.HashMap; |
58 | 87 | import java.util.HashSet; |
@@ -2145,4 +2174,183 @@ protected Client.Builder newClient() { |
2145 | 2174 | .setDefaultDatabase(ClickHouseServerForTest.getDatabase()) |
2146 | 2175 | .serverSetting(ServerSettings.WAIT_END_OF_QUERY, "1"); |
2147 | 2176 | } |
| 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 | + } |
2148 | 2356 | } |
0 commit comments