diff --git a/README.md b/README.md index 01aa3370..af6d922a 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,19 @@ try (QuestDB db = QuestDB.connect("wss::addr=localhost:9000;username=admin;passw } ``` +**Custom PEM certificate authority:** + +```java +try (QuestDB db = QuestDB.connect( + "wss::addr=localhost:9000;tls_roots=/path/to/ca.pem;")) { + // ... use db ... +} +``` + +`tls_roots` accepts a PEM certificate or bundle directly. For an existing JKS +or PKCS#12 trust store, also set `tls_roots_password`; the password switches the +file interpretation from PEM to a Java trust store. + **Disable certificate validation (not for production):** ```java @@ -412,8 +425,8 @@ schema::key1=value1;key2=value2; | `password` / `pass` | | Basic-auth password | | `token` | | Bearer token (sent as an `Authorization` header on the WS upgrade) | | `tls_verify` | `on` | TLS certificate validation (`on` or `unsafe_off`) | -| `tls_roots` | | Path to a custom truststore | -| `tls_roots_password` | | Truststore password | +| `tls_roots` | | Path to a PEM certificate/bundle, or a JKS/PKCS#12 trust store | +| `tls_roots_password` | | Optional JKS/PKCS#12 password; omit when `tls_roots` is PEM | | `connect_timeout` | _(OS)_ | TCP connect + TLS handshake timeout, in milliseconds | | `auth_timeout_ms` | `15000` | Authentication/upgrade request timeout, in milliseconds | diff --git a/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java b/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java index 93394893..dc982ae4 100644 --- a/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java +++ b/core/src/main/java/io/questdb/client/ClientTlsConfiguration.java @@ -33,6 +33,9 @@ public class ClientTlsConfiguration { private final String trustStorePath; public ClientTlsConfiguration(String trustStorePath, char[] trustStorePassword, int tlsValidationMode) { + if (trustStorePath != null && tlsValidationMode == TLS_VALIDATION_MODE_NONE) { + throw new IllegalArgumentException("custom trust store cannot be combined with disabled TLS validation"); + } this.trustStorePath = trustStorePath; this.trustStorePassword = trustStorePassword; this.tlsValidationMode = tlsValidationMode; diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index aa500d44..5106da96 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -934,6 +934,7 @@ enum Transport { */ final class LineSenderBuilder { private static final int AUTO_FLUSH_DISABLED = 0; + private static final String TLS_ROOTS_INSECURE_CONFIG_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation"; // close() drain timeout. Default applied at build() time. 0 or -1 // means "fast close" (skip the drain entirely); any positive value // bounds the wait for ackedFsn to catch up to publishedFsn. Uses @@ -1415,7 +1416,7 @@ public Sender build() { } ClientTlsConfiguration tlsConfig = null; if (tlsEnabled) { - assert (trustStorePath == null) == (trustStorePassword == null); //either both null or both non-null + assert trustStorePassword == null || trustStorePath != null; tlsConfig = new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode == TlsValidationMode.DEFAULT ? ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL : ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE); } return AbstractLineHttpSender.createLineSender(hosts, ports, httpPath, httpClientConfiguration, tlsConfig, actualAutoFlushRows, httpToken, @@ -1437,7 +1438,7 @@ public Sender build() { ClientTlsConfiguration wsTlsConfig = null; if (tlsEnabled) { - assert (trustStorePath == null) == (trustStorePassword == null); + assert trustStorePassword == null || trustStorePath != null; wsTlsConfig = new ClientTlsConfiguration( trustStorePath, trustStorePassword, @@ -3573,13 +3574,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { if (hosts.size() == 0) { throw new LineSenderException("addr is missing"); } - if (trustStorePath != null) { - if (trustStorePassword == null) { - throw new LineSenderException("tls_roots was configured, but tls_roots_password is missing"); - } - } else if (trustStorePassword != null) { + if (trustStorePath == null && trustStorePassword != null) { throw new LineSenderException("tls_roots_password was configured, but tls_roots is missing"); } + if (trustStorePath != null && tlsValidationMode == TlsValidationMode.INSECURE) { + throw new LineSenderException(TLS_ROOTS_INSECURE_CONFIG_ERROR); + } if (protocol == PROTOCOL_HTTP || protocol == PROTOCOL_WEBSOCKET) { if (user != null) { httpUsernamePassword(user, password); @@ -3836,8 +3836,11 @@ static void validateWsConfig(ConfigView view, boolean tls) { if (!tls && (tlsVerify != null || tlsRoots != null || tlsRootsPassword != null)) { throw new IllegalArgumentException("tls_verify/tls_roots/tls_roots_password require the wss:: schema"); } - if ((tlsRoots == null) != (tlsRootsPassword == null)) { - throw new IllegalArgumentException("tls_roots and tls_roots_password must be provided together"); + if (tlsRoots == null && tlsRootsPassword != null) { + throw new IllegalArgumentException("tls_roots_password requires tls_roots"); + } + if (tlsRoots != null && "unsafe_off".equals(tlsVerify)) { + throw new IllegalArgumentException(TLS_ROOTS_INSECURE_CONFIG_ERROR); } } @@ -3975,6 +3978,9 @@ private void validateParameters() { if (!tlsEnabled && tlsValidationMode != TlsValidationMode.DEFAULT) { throw new LineSenderException("TLS validation disabled, but TLS was not enabled"); } + if (trustStorePath != null && tlsValidationMode == TlsValidationMode.INSECURE) { + throw new LineSenderException("custom trust store cannot be combined with disabled TLS validation"); + } if (keyId != null && bufferCapacity < MIN_BUFFER_SIZE) { throw new LineSenderException("Requested buffer too small ") .put("[minimalCapacity=").put(MIN_BUFFER_SIZE) @@ -4133,17 +4139,42 @@ private void websocket() { public class AdvancedTlsSettings { /** - * Configure a custom truststore. This is only needed when using {@link #enableTls()} when your default - * truststore does not contain certificate chain used by a server. Most users should not need it. + * Configure a PEM file containing one or more custom root certificates. + * This is only needed when using {@link #enableTls()} and the default + * trust store does not contain the certificate chain used by a server. + * Most users should not need it. *
- * The path can be either a path on a local filesystem. Or you can prefix it with "classpath:" to instruct - * the Sender to load a trust store from a classpath. + * The path can be on the local filesystem, or it can use the + * {@code classpath:} prefix. + * + * @param pemRootsPath a path to a PEM certificate file or bundle + * @return an instance of LineSenderBuilder for further configuration + */ + public LineSenderBuilder customTrustStore(String pemRootsPath) { + return setCustomTrustStore(pemRootsPath, null); + } + + /** + * Configure a password-protected JKS or PKCS#12 trust store. This is + * only needed when using {@link #enableTls()} and the default trust + * store does not contain the certificate chain used by a server. + * Most users should not need it. + *
+ * The path can be on the local filesystem, or it can use the + * {@code classpath:} prefix. * * @param trustStorePath a path to a trust store. - * @param trustStorePassword a password to for the truststore + * @param trustStorePassword the trust store password * @return an instance of LineSenderBuilder for further configuration */ public LineSenderBuilder customTrustStore(String trustStorePath, char[] trustStorePassword) { + if (trustStorePassword == null) { + throw new LineSenderException("trust store password cannot be null"); + } + return setCustomTrustStore(trustStorePath, trustStorePassword); + } + + private LineSenderBuilder setCustomTrustStore(String trustStorePath, char[] trustStorePassword) { if (LineSenderBuilder.this.trustStorePath != null) { throw new LineSenderException("custom trust store was already configured ") .put("[path=").put(LineSenderBuilder.this.trustStorePath).put("]"); @@ -4151,8 +4182,8 @@ public LineSenderBuilder customTrustStore(String trustStorePath, char[] trustSto if (Chars.isBlank(trustStorePath)) { throw new LineSenderException("trust store path cannot be empty nor null"); } - if (trustStorePassword == null) { - throw new LineSenderException("trust store password cannot be null"); + if (tlsValidationMode == TlsValidationMode.INSECURE) { + throw new LineSenderException("custom trust store cannot be configured when TLS validation is disabled"); } LineSenderBuilder.this.trustStorePath = trustStorePath; @@ -4165,12 +4196,16 @@ public LineSenderBuilder customTrustStore(String trustStorePath, char[] trustSto * This is suitable when testing self-signed certificate. It's inherently insecure and should * never be used in a production. *
- * If you cannot use trusted certificate then you should prefer {@link #customTrustStore(String, char[])} - * over disabling validation. + * If you cannot use a certificate in the default trust store then + * you should prefer {@link #customTrustStore(String)} or + * {@link #customTrustStore(String, char[])} over disabling validation. * * @return an instance of LineSenderBuilder for further configuration */ public LineSenderBuilder disableCertificateValidation() { + if (LineSenderBuilder.this.trustStorePath != null) { + throw new LineSenderException("TLS validation cannot be disabled when a custom trust store is configured"); + } LineSenderBuilder.this.tlsValidationMode = TlsValidationMode.INSECURE; return LineSenderBuilder.this; } diff --git a/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java b/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java index 659b1574..b0f96aa2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java +++ b/core/src/main/java/io/questdb/client/cutlass/line/tcp/DelegatingTlsChannel.java @@ -27,6 +27,7 @@ import io.questdb.client.Sender; import io.questdb.client.cutlass.line.LineChannel; import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.network.TlsTrustStore; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Misc; import io.questdb.client.std.Unsafe; @@ -34,39 +35,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLEngineResult; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.InputStream; import java.lang.reflect.Field; import java.nio.Buffer; import java.nio.ByteBuffer; -import java.security.KeyStore; -import java.security.SecureRandom; -import java.security.cert.X509Certificate; public final class DelegatingTlsChannel implements LineChannel { private static final long ADDRESS_FIELD_OFFSET; private static final int AFTER_HANDSHAKE = 1; - private static final TrustManager[] BLIND_TRUST_MANAGERS = new TrustManager[]{new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] certs, String t) { - } - - public void checkServerTrusted(X509Certificate[] certs, String t) { - } - - public X509Certificate[] getAcceptedIssuers() { - return null; - } - }}; - private static final long CAPACITY_FIELD_OFFSET; private static final int CLOSED = 3; private static final int CLOSING = 2; @@ -202,44 +180,14 @@ private static long allocateMemoryAndResetBuffer(ByteBuffer buffer, int capacity } private static SSLEngine createSslEngine(String trustStorePath, char[] trustStorePassword, Sender.TlsValidationMode validationMode, String peerHost) { - assert trustStorePath == null || validationMode == Sender.TlsValidationMode.DEFAULT; try { - SSLContext sslContext; - if (trustStorePath != null) { - sslContext = SSLContext.getInstance("TLS"); - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - KeyStore jks = KeyStore.getInstance("JKS"); - try (InputStream trustStoreStream = openTruststoreStream(trustStorePath)) { - jks.load(trustStoreStream, trustStorePassword); - } - tmf.init(jks); - TrustManager[] trustManagers = tmf.getTrustManagers(); - sslContext.init(null, trustManagers, new SecureRandom()); - } else if (validationMode == Sender.TlsValidationMode.INSECURE) { - sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, BLIND_TRUST_MANAGERS, new SecureRandom()); - } else { - sslContext = SSLContext.getDefault(); - } - - // SSLEngine needs to know hostname during TLS handshake to validate a server certificate was issued - // for the server we are connecting to. For details see the comment below. - // Hostname validation does not use port at all hence we can get away with a dummy value -1 - SSLEngine sslEngine = sslContext.createSSLEngine(peerHost, -1); - if (validationMode != Sender.TlsValidationMode.INSECURE) { - SSLParameters sslParameters = sslEngine.getSSLParameters(); - // The https validation algorithm? That looks confusing! After all we are not using any - // https here at so what does it mean? - // It's actually simple: It just instructs the SSLEngine to perform the same hostname validation - // as it does during HTTPS connections. SSLEngine does not do hostname validation by default. Without - // this option SSLEngine would happily accept any certificate as long as it's signed by a trusted CA. - // This option will make sure certificates are accepted only if they were issued for the - // server we are connecting to. - sslParameters.setEndpointIdentificationAlgorithm("https"); - sslEngine.setSSLParameters(sslParameters); - } - sslEngine.setUseClientMode(true); - return sslEngine; + return TlsTrustStore.createSslEngine( + trustStorePath, + trustStorePassword, + validationMode == Sender.TlsValidationMode.INSECURE, + peerHost, + DelegatingTlsChannel.class + ); } catch (Throwable t) { if (t instanceof LineSenderException) { throw (LineSenderException) t; @@ -256,20 +204,6 @@ private static long expandBuffer(ByteBuffer buffer, long oldAddress) { return newAddress; } - private static InputStream openTruststoreStream(String trustStorePath) throws FileNotFoundException { - InputStream trustStoreStream; - if (trustStorePath.startsWith("classpath:")) { - String adjustedPath = trustStorePath.substring("classpath:".length()); - trustStoreStream = DelegatingTlsChannel.class.getResourceAsStream(adjustedPath); - if (trustStoreStream == null) { - throw new LineSenderException("configured trust store is unavailable ") - .put("[path=").put(trustStorePath).put("]"); - } - return trustStoreStream; - } - return new FileInputStream(trustStorePath); - } - private static void resetBufferToPointer(ByteBuffer buffer, long ptr, int len) { assert buffer.isDirect(); Unsafe.getUnsafe().putLong(buffer, ADDRESS_FIELD_OFFSET, ptr); @@ -437,4 +371,4 @@ private void writeToUpstreamAndClear() { LIMIT_FIELD_OFFSET = Unsafe.getUnsafe().objectFieldOffset(limitField); CAPACITY_FIELD_OFFSET = Unsafe.getUnsafe().objectFieldOffset(capacityField); } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index f6587631..1bc478dc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -140,6 +140,7 @@ public class QwpQueryClient implements QuietCloseable { private static final long DEFAULT_FAILOVER_MAX_BACKOFF_MS = 1_000L; private static final long DEFAULT_FAILOVER_MAX_DURATION_MS = 30_000L; private static final int DEFAULT_IO_BUFFER_POOL_SIZE = 4; + private static final String TLS_ROOTS_INSECURE_CONFIG_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation"; /** * How long {@link #connect()} waits to read the {@code SERVER_INFO} frame * from each endpoint before giving up and moving to the next. 5 seconds is @@ -345,9 +346,10 @@ private QwpQueryClient(String host, int port) { *
  • {@code tls_verify=on|unsafe_off} -- TLS certificate validation. Default is {@code on}. * Only allowed with the {@code wss::} schema. {@code unsafe_off} disables hostname and * certificate chain validation; use only for testing.
  • - *
  • {@code tls_roots=} -- path to a custom trust store (PKCS12 or JKS). Must be - * paired with {@code tls_roots_password}. Only allowed with {@code wss::}.
  • - *
  • {@code tls_roots_password=} -- password for the custom trust store.
  • + *
  • {@code tls_roots=} -- path to a PEM certificate file or bundle. Only allowed + * with {@code wss::}.
  • + *
  • {@code tls_roots_password=} -- optional password. When present, + * {@code tls_roots} is read as a JKS or PKCS#12 trust store instead of PEM.
  • *
  • {@code zone=} -- client zone identifier (opaque, case-insensitive; * e.g. {@code eu-west-1a}). When set with {@code target=any|replica}, * failover prefers endpoints whose server-advertised {@code zone_id} @@ -462,7 +464,11 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { client.withCompression(compression, compressionLevel); if (tls) { if (tlsRoots != null) { - client.withTrustStore(tlsRoots, tlsRootsPassword.toCharArray()); + if (tlsRootsPassword == null) { + client.withTrustStore(tlsRoots); + } else { + client.withTrustStore(tlsRoots, tlsRootsPassword.toCharArray()); + } } else if (tlsValidation != null && tlsValidation == ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE) { client.withInsecureTls(); } else { @@ -542,8 +548,11 @@ public static void validateConfig(ConfigView view, boolean tls) { throw new IllegalArgumentException( "tls_verify/tls_roots/tls_roots_password require the wss:: schema"); } - if ((tlsRoots == null) != (tlsRootsPassword == null)) { - throw new IllegalArgumentException("tls_roots and tls_roots_password must be provided together"); + if (tlsRoots == null && tlsRootsPassword != null) { + throw new IllegalArgumentException("tls_roots_password requires tls_roots"); + } + if (tlsRoots != null && "unsafe_off".equals(tlsVerify)) { + throw new IllegalArgumentException(TLS_ROOTS_INSECURE_CONFIG_ERROR); } // Mirror fromConfig's effective values: a missing bound takes its // default, so the ordering is enforced even when only one key is set @@ -1215,7 +1224,8 @@ public QwpQueryClient withInitialCredit(long bytes) { /** * Enables TLS with certificate validation disabled. Intended for testing only -- - * production code should use {@link #withTls} or {@link #withTrustStore}. + * production code should use {@link #withTls} or + * {@link #withTrustStore(String)}. * Must be called before {@link #connect}. */ public QwpQueryClient withInsecureTls() { @@ -1286,7 +1296,27 @@ public QwpQueryClient withTls() { } /** - * Enables TLS with full certificate validation against the given custom trust store. + * Enables TLS with full certificate validation against the custom root + * certificates in the given PEM file or bundle. + * Must be called before {@link #connect}. + * + * @param pemRootsPath filesystem path to a PEM certificate file or bundle + */ + public QwpQueryClient withTrustStore(String pemRootsPath) { + checkPreConnect("withTrustStore"); + if (pemRootsPath == null) { + throw new IllegalArgumentException("pemRootsPath must not be null"); + } + this.tlsEnabled = true; + this.tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL; + this.trustStorePath = pemRootsPath; + this.trustStorePassword = null; + return this; + } + + /** + * Enables TLS with full certificate validation against the given + * password-protected JKS or PKCS#12 trust store. * Must be called before {@link #connect}. * * @param trustStorePath filesystem path to a PKCS12 or JKS trust store diff --git a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java index f4dc94df..4649b596 100644 --- a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java +++ b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java @@ -25,7 +25,6 @@ package io.questdb.client.network; import io.questdb.client.ClientTlsConfiguration; -import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.line.tcp.DelegatingTlsChannel; import io.questdb.client.std.Chars; import io.questdb.client.std.MemoryTag; @@ -34,43 +33,21 @@ import org.jetbrains.annotations.TestOnly; import org.slf4j.Logger; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLEngineResult; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; -import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.lang.reflect.Field; import java.nio.Buffer; import java.nio.ByteBuffer; import java.security.KeyManagementException; -import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; public final class JavaTlsClientSocket implements Socket { private static final long ADDRESS_FIELD_OFFSET; - private static final TrustManager[] BLIND_TRUST_MANAGERS = new TrustManager[]{new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] certs, String t) { - } - - public void checkServerTrusted(X509Certificate[] certs, String t) { - } - - public X509Certificate[] getAcceptedIssuers() { - return null; - } - }}; private static final long CAPACITY_FIELD_OFFSET; private static final int INITIAL_BUFFER_CAPACITY_BYTES = 256 * 1024; private static final long LIMIT_FIELD_OFFSET; @@ -395,20 +372,6 @@ private static long expandBuffer(ByteBuffer buffer, long oldAddress) { return newAddress; } - private static InputStream openTrustStoreStream(String trustStorePath) throws FileNotFoundException { - InputStream trustStoreStream; - if (trustStorePath.startsWith("classpath:")) { - String adjustedPath = trustStorePath.substring("classpath:".length()); - trustStoreStream = DelegatingTlsChannel.class.getResourceAsStream(adjustedPath); - if (trustStoreStream == null) { - throw new LineSenderException("configured trust store is unavailable ") - .put("[path=").put(trustStorePath).put("]"); - } - return trustStoreStream; - } - return new FileInputStream(trustStorePath); - } - private static void resetBufferToPointer(ByteBuffer buffer, long ptr, int len) { assert buffer.isDirect(); Unsafe.getUnsafe().putLong(buffer, ADDRESS_FIELD_OFFSET, ptr); @@ -418,42 +381,13 @@ private static void resetBufferToPointer(ByteBuffer buffer, long ptr, int len) { } private SSLEngine createSslEngine(CharSequence serverName) throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException { - SSLContext sslContext; - String trustStorePath = tlsConfig.trustStorePath(); - int tlsValidationMode = tlsConfig.tlsValidationMode(); - if (trustStorePath != null) { - sslContext = SSLContext.getInstance("TLS"); - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - KeyStore jks = KeyStore.getInstance("JKS"); - try (InputStream trustStoreStream = openTrustStoreStream(trustStorePath)) { - jks.load(trustStoreStream, tlsConfig.trustStorePassword()); - } - tmf.init(jks); - TrustManager[] trustManagers = tmf.getTrustManagers(); - sslContext.init(null, trustManagers, new SecureRandom()); - } else if (tlsValidationMode == ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE) { - sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, BLIND_TRUST_MANAGERS, new SecureRandom()); - } else { - sslContext = SSLContext.getDefault(); - } - - SSLEngine sslEngine = sslContext.createSSLEngine(Chars.toString(serverName), -1); - if (tlsValidationMode != ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE) { - SSLParameters sslParameters = sslEngine.getSSLParameters(); - // The https validation algorithm? That looks confusing! After all we are not using any - // https here at so what does it mean? - // It's actually simple: It just instructs the SSLEngine to perform the same hostname validation - // as it does during HTTPS connections. SSLEngine does not do hostname validation by default. Without - // this option SSLEngine would happily accept any certificate as long as it's signed by a trusted CA. - // This option will make sure certificates are accepted only if they were issued for the - // server we are connecting to. - sslParameters.setEndpointIdentificationAlgorithm("https"); - sslEngine.setSSLParameters(sslParameters); - } - - sslEngine.setUseClientMode(true); - return sslEngine; + return TlsTrustStore.createSslEngine( + tlsConfig.trustStorePath(), + tlsConfig.trustStorePassword(), + tlsConfig.tlsValidationMode() == ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE, + Chars.toString(serverName), + DelegatingTlsChannel.class + ); } private void freeInternalBuffers() { diff --git a/core/src/main/java/io/questdb/client/network/TlsTrustStore.java b/core/src/main/java/io/questdb/client/network/TlsTrustStore.java new file mode 100644 index 00000000..1fbdce23 --- /dev/null +++ b/core/src/main/java/io/questdb/client/network/TlsTrustStore.java @@ -0,0 +1,192 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.network; + +import io.questdb.client.cutlass.line.LineSenderException; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Collection; + +/** + * Internal factory for client-side TLS: loads custom TLS roots and builds + * the {@link SSLEngine} used by both the ILP TCP channel and the WS socket. + *

    + * A {@code null} trust store password selects a PEM certificate bundle. A + * non-null password retains the historical Java KeyStore behaviour. + */ +public final class TlsTrustStore { + + private static final TrustManager[] BLIND_TRUST_MANAGERS = new TrustManager[]{new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] certs, String t) { + } + + public void checkServerTrusted(X509Certificate[] certs, String t) { + } + + public X509Certificate[] getAcceptedIssuers() { + return null; + } + }}; + + private TlsTrustStore() { + } + + public static SSLEngine createSslEngine( + String trustStorePath, + char[] trustStorePassword, + boolean insecure, + String peerHost, + Class resourceAnchor + ) throws CertificateException, IOException, KeyManagementException, KeyStoreException, NoSuchAlgorithmException { + if (trustStorePath != null && insecure) { + throw new IllegalArgumentException("custom trust store cannot be combined with disabled TLS validation"); + } + SSLContext sslContext; + if (trustStorePath != null) { + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, load(trustStorePath, trustStorePassword, resourceAnchor), new SecureRandom()); + } else if (insecure) { + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, BLIND_TRUST_MANAGERS, new SecureRandom()); + } else { + sslContext = SSLContext.getDefault(); + } + + // SSLEngine needs to know hostname during TLS handshake to validate a server certificate was issued + // for the server we are connecting to. For details see the comment below. + // Hostname validation does not use port at all hence we can get away with a dummy value -1 + SSLEngine sslEngine = sslContext.createSSLEngine(peerHost, -1); + if (!insecure) { + SSLParameters sslParameters = sslEngine.getSSLParameters(); + // The https validation algorithm? That looks confusing! After all we are not using any + // https here at so what does it mean? + // It's actually simple: It just instructs the SSLEngine to perform the same hostname validation + // as it does during HTTPS connections. SSLEngine does not do hostname validation by default. Without + // this option SSLEngine would happily accept any certificate as long as it's signed by a trusted CA. + // This option will make sure certificates are accepted only if they were issued for the + // server we are connecting to. + sslParameters.setEndpointIdentificationAlgorithm("https"); + sslEngine.setSSLParameters(sslParameters); + } + sslEngine.setUseClientMode(true); + return sslEngine; + } + + public static TrustManager[] load( + String trustStorePath, + char[] trustStorePassword, + Class resourceAnchor + ) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException { + KeyStore trustStore = trustStorePassword == null + ? loadPem(trustStorePath, resourceAnchor) + : loadJavaKeyStore(trustStorePath, trustStorePassword, resourceAnchor); + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + return trustManagerFactory.getTrustManagers(); + } + + private static KeyStore loadJavaKeyStore( + String trustStorePath, + char[] trustStorePassword, + Class resourceAnchor + ) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException { + try (BufferedInputStream trustStoreStream = + new BufferedInputStream(open(trustStorePath, resourceAnchor))) { + trustStoreStream.mark(1); + int firstByte = trustStoreStream.read(); + trustStoreStream.reset(); + String keyStoreType = firstByte == 0x30 ? "PKCS12" : "JKS"; + KeyStore trustStore = KeyStore.getInstance(keyStoreType); + trustStore.load(trustStoreStream, trustStorePassword); + return trustStore; + } + } + + private static KeyStore loadPem( + String trustStorePath, + Class resourceAnchor + ) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException { + Collection certificates; + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + try (InputStream trustStoreStream = open(trustStorePath, resourceAnchor)) { + try { + certificates = certificateFactory.generateCertificates(trustStoreStream); + } catch (CertificateException e) { + throw new CertificateException( + "could not read PEM TLS roots [path=" + trustStorePath + "]; " + + "if this is a JKS or PKCS#12 trust store, set tls_roots_password", + e + ); + } + } + if (certificates.isEmpty()) { + throw new CertificateException( + "no X.509 certificates found in PEM TLS roots [path=" + trustStorePath + "]; " + + "if this is a JKS or PKCS#12 trust store, set tls_roots_password" + ); + } + + KeyStore trustStore = KeyStore.getInstance("JKS"); + trustStore.load(null, null); + int certificateIndex = 0; + for (Certificate certificate : certificates) { + trustStore.setCertificateEntry("pem-certificate-" + certificateIndex++, certificate); + } + return trustStore; + } + + private static InputStream open(String trustStorePath, Class resourceAnchor) throws FileNotFoundException { + if (trustStorePath.startsWith("classpath:")) { + String adjustedPath = trustStorePath.substring("classpath:".length()); + InputStream trustStoreStream = resourceAnchor.getResourceAsStream(adjustedPath); + if (trustStoreStream == null) { + throw new LineSenderException("configured trust store is unavailable ") + .put("[path=").put(trustStorePath).put("]"); + } + return trustStoreStream; + } + return new FileInputStream(trustStorePath); + } +} diff --git a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java index 6b7cacf8..6cf5eb09 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -114,6 +114,14 @@ public void testMalformedIngressConfigRejectedAtBuildWithMinZero() { "ws::addr=127.0.0.1:1;sf_durability=flush;sender_pool_min=0;query_pool_min=0;", "not yet supported"); } + @Test + public void testTlsRootsWithUnsafeOffRejectedAtBuildWithMinZero() { + assertBuildRejected( + "wss::addr=127.0.0.1:1;tls_roots=/ca.pem;tls_verify=unsafe_off;sender_pool_min=0;query_pool_min=0;", + "tls_roots cannot be combined with tls_verify=unsafe_off" + ); + } + @Test public void testMalformedPoolValueRejectedAtBuild() { // A non-numeric pool value is rejected at build()'s pool-key resolution, diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java index 30a66f98..76a0eb50 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java @@ -44,6 +44,7 @@ public class LineSenderBuilderTest { private static final String AUTH_TOKEN_KEY1 = "UvuVb1USHGRRT08gEnwN2zGZrvM4MsLQ5brgF6SVkAw="; private static final String LOCALHOST = "localhost"; + private static final String TLS_ROOTS_INSECURE_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation"; private static final char[] TRUSTSTORE_PASSWORD = "questdb".toCharArray(); private static final String TRUSTSTORE_PATH = "/keystore/server.keystore"; @@ -184,8 +185,12 @@ public void testConfStringValidation() throws Exception { assertConfStrError("badschema::addr=bar;", "invalid schema [schema=badschema, supported-schemas=[http, https, tcp, tcps, ws, wss, udp]]"); assertConfStrError("http::addr=localhost:-1;", "invalid port [port=-1]"); assertConfStrError("http::auto_flush=on;", "addr is missing"); - assertConfStrError("http::addr=localhost;tls_roots=/some/path;", "tls_roots was configured, but tls_roots_password is missing"); + assertConfStrError("http::addr=localhost;tls_roots=/some/path;", "custom trust store configured, but TLS was not enabled"); + assertConfStrOk("https::addr=localhost;tls_roots=/some/path;protocol_version=2;"); assertConfStrError("http::addr=localhost;tls_roots_password=hunter123;", "tls_roots_password was configured, but tls_roots is missing"); + assertConfStrError("tcps::addr=localhost;tls_roots=/ca.pem;tls_verify=unsafe_off;", TLS_ROOTS_INSECURE_ERROR); + assertConfStrError("https::addr=localhost;tls_verify=unsafe_off;tls_roots=/ca.pem;protocol_version=2;", TLS_ROOTS_INSECURE_ERROR); + assertConfStrError("tcps::addr=localhost;tls_roots=/ca.p12;tls_roots_password=secret;tls_verify=unsafe_off;", TLS_ROOTS_INSECURE_ERROR); assertConfStrError("tcp::addr=localhost;user=foo;", "token cannot be empty nor null"); assertConfStrError("tcp::addr=localhost;username=foo;", "token cannot be empty nor null"); assertConfStrError("tcp::addr=localhost;token=foo;", "TCP token is configured, but user is missing"); @@ -306,8 +311,46 @@ public void testCustomTruststorePasswordCannotBeNull() { () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(TRUSTSTORE_PATH, null)); } + @Test + public void testCustomTruststoreAndDisabledValidationCannotBeCombinedViaRetainedSettings() { + Sender.LineSenderBuilder customRootsBuilder = Sender.builder(Sender.Transport.TCP).enableTls(); + Sender.LineSenderBuilder.AdvancedTlsSettings customRootsSettings = customRootsBuilder.advancedTls(); + customRootsSettings.customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD); + assertThrows( + "TLS validation cannot be disabled when a custom trust store is configured", + customRootsSettings::disableCertificateValidation + ); + + Sender.LineSenderBuilder insecureBuilder = Sender.builder(Sender.Transport.TCP).enableTls(); + Sender.LineSenderBuilder.AdvancedTlsSettings insecureSettings = insecureBuilder.advancedTls(); + insecureSettings.disableCertificateValidation(); + assertThrows( + "custom trust store cannot be configured when TLS validation is disabled", + () -> insecureSettings.customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD) + ); + } + + @Test + public void testCustomPemRootsDoNotRequirePassword() throws Exception { + assertMemoryLeak(() -> { + try (Sender ignored = Sender.builder(Sender.Transport.HTTP) + .enableTls() + .advancedTls().customTrustStore("/path/to/ca.pem") + .address(LOCALHOST) + .protocolVersion(2) + .build()) { + // Building the lazy HTTP sender proves the fluent TLS + // configuration accepts a passwordless PEM roots path. + } + }); + } + @Test public void testCustomTruststorePathCannotBeBlank() { + assertThrows("trust store path cannot be empty nor null", + () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore("")); + assertThrows("trust store path cannot be empty nor null", + () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore((String) null)); assertThrows("trust store path cannot be empty nor null", () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore("", TRUSTSTORE_PASSWORD)); assertThrows("trust store path cannot be empty nor null", diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java index fbbc5313..42dbaa51 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java @@ -1029,6 +1029,26 @@ public void testWsConfigString_tlsKeysOnNonTlsSchema_fails() { assertBadConfig("ws::addr=localhost:9000;tls_roots=/ca.p12;", "require the wss:: schema"); } + @Test + public void testWssConfigString_tlsRootsPasswordWithoutRoots_fails() { + assertBadConfig( + "wss::addr=localhost:9000;tls_roots_password=secret;", + "tls_roots_password requires tls_roots" + ); + } + + @Test + public void testWssConfigString_tlsRootsWithUnsafeOff_fails() { + assertBadConfig( + "wss::addr=localhost:9000;tls_roots=/ca.pem;tls_verify=unsafe_off;", + "tls_roots cannot be combined with tls_verify=unsafe_off" + ); + assertBadConfig( + "wss::addr=localhost:9000;tls_verify=unsafe_off;tls_roots=/ca.p12;tls_roots_password=secret;", + "tls_roots cannot be combined with tls_verify=unsafe_off" + ); + } + @Test public void testWsConfigString_withToken() throws Exception { assertMemoryLeak(() -> { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java index 42d668fb..daf55bb9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java @@ -40,6 +40,8 @@ */ public class QwpQueryClientFromConfigTest { + private static final String TLS_ROOTS_INSECURE_ERROR = "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation"; + @Test public void testAddrAcceptsHostWithoutPort() { // Host-only accepted; port defaults to the public DEFAULT_WS_PORT constant. @@ -974,7 +976,7 @@ public void testTlsRootsOnWsRejected() { public void testTlsRootsPasswordWithoutPathRejected() { assertReject( "wss::addr=db:9000;tls_roots_password=secret;", - "tls_roots and tls_roots_password must be provided together" + "tls_roots_password requires tls_roots" ); } @@ -984,13 +986,22 @@ public void testTlsRootsWithPasswordAccepted() { } @Test - public void testTlsRootsWithoutPasswordRejected() { + public void testTlsRootsWithUnsafeOffRejected() { + assertReject( + "wss::addr=db:9000;tls_roots=/etc/qdb/ca.pem;tls_verify=unsafe_off;", + TLS_ROOTS_INSECURE_ERROR + ); assertReject( - "wss::addr=db:9000;tls_roots=/etc/qdb/ca.p12;", - "tls_roots and tls_roots_password must be provided together" + "wss::addr=db:9000;tls_verify=unsafe_off;tls_roots=/etc/qdb/ca.p12;tls_roots_password=secret;", + TLS_ROOTS_INSECURE_ERROR ); } + @Test + public void testPemTlsRootsWithoutPasswordAccepted() { + assertParses("wss::addr=db:9000;tls_roots=/etc/qdb/ca.pem;"); + } + @Test public void testTlsVerifyInvalidRejected() { assertReject( diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java index d4ad155e..a220084a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java @@ -80,6 +80,7 @@ public void testAllSettersRejectAfterConnect() throws Exception { // withTls assertRejects(QwpQueryClient::withTls, "withTls"); // withTrustStore + assertRejects(c -> c.withTrustStore("/tmp/ca.pem"), "withTrustStore"); assertRejects(c -> c.withTrustStore("/tmp/ts.p12", "pw".toCharArray()), "withTrustStore"); } diff --git a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java index 00313004..10b1e3c0 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java @@ -80,10 +80,13 @@ public void testEveryEgressKeyIsHonored() throws Exception { markHonored("username", "password", "token"); // COMMON TLS keys applied by egress (require the wss schema). tls_verify - // drives the validation mode; tls_roots/tls_roots_password set the trust - // store. All three read back from the snapshot. + // drives the validation mode. A passwordless roots path is PEM; + // supplying a password retains the JKS/PKCS12 trust-store path. Assert.assertEquals(ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE, snapshot("wss::addr=h:9000;tls_verify=unsafe_off;").get("tls_verify")); + Map pem = snapshot("wss::addr=h:9000;tls_roots=/ca.pem;"); + Assert.assertEquals("/ca.pem", pem.get("tls_roots")); + Assert.assertNull(pem.get("tls_roots_password")); Map tls = snapshot("wss::addr=h:9000;tls_roots=/ca.p12;tls_roots_password=pw;"); Assert.assertEquals("/ca.p12", tls.get("tls_roots")); Assert.assertEquals("pw", tls.get("tls_roots_password")); diff --git a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java index e7be38ed..63c10516 100644 --- a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java @@ -91,8 +91,12 @@ public void testEveryIngressKeyIsHonored() { Assert.assertEquals("pw", aliasCreds.get("password")); markHonored("username", "password"); - // tls keys require wss; tls_roots must be paired with its password. + // TLS keys require wss. A passwordless roots path is PEM; supplying a + // password retains the JKS/PKCS12 trust-store path. assertHonoredWss("tls_verify=unsafe_off", "tls_verify", "INSECURE"); + Map pem = snapshot("wss::addr=h:9000;tls_roots=/ca.pem;"); + Assert.assertEquals("/ca.pem", pem.get("tls_roots")); + Assert.assertNull(pem.get("tls_roots_password")); Map tls = snapshot("wss::addr=h:9000;tls_roots=/ca.p12;tls_roots_password=pw;"); Assert.assertEquals("/ca.p12", tls.get("tls_roots")); Assert.assertEquals("pw", tls.get("tls_roots_password")); diff --git a/core/src/test/java/io/questdb/client/test/network/TlsTrustStoreTest.java b/core/src/test/java/io/questdb/client/test/network/TlsTrustStoreTest.java new file mode 100644 index 00000000..edbc2f88 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/network/TlsTrustStoreTest.java @@ -0,0 +1,231 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.network; + +import io.questdb.client.ClientTlsConfiguration; +import io.questdb.client.network.TlsTrustStore; +import org.junit.Assert; +import org.junit.Test; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +public class TlsTrustStoreTest { + + private static final String RESOURCE_PREFIX = "/io/questdb/client/test/network/"; + private static final String TLS_ROOTS_INSECURE_ERROR = "custom trust store cannot be combined with disabled TLS validation"; + + @Test + public void testClientTlsConfigurationRejectsCustomRootsWithDisabledValidation() { + try { + new ClientTlsConfiguration( + "/ca.pem", + null, + ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE + ); + Assert.fail("expected custom roots with disabled validation to be rejected"); + } catch (IllegalArgumentException e) { + Assert.assertEquals(TLS_ROOTS_INSECURE_ERROR, e.getMessage()); + } + } + + @Test + public void testCreateSslEngineRejectsCustomRootsWithDisabledValidation() throws Exception { + try { + TlsTrustStore.createSslEngine( + "/ca.pem", + null, + true, + "localhost", + TlsTrustStoreTest.class + ); + Assert.fail("expected custom roots with disabled validation to be rejected"); + } catch (IllegalArgumentException e) { + Assert.assertEquals(TLS_ROOTS_INSECURE_ERROR, e.getMessage()); + } + } + + @Test + public void testJksTrustStoreStillSupported() throws Exception { + X509Certificate rootCertificate = loadCertificate("server-rootCA.pem"); + Path trustStorePath = Files.createTempFile("questdb-tls-roots", ".jks"); + char[] password = "questdb".toCharArray(); + try { + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(null, null); + keyStore.setCertificateEntry("test-root", rootCertificate); + try (OutputStream output = Files.newOutputStream(trustStorePath)) { + keyStore.store(output, password); + } + + X509TrustManager trustManager = x509TrustManager( + TlsTrustStore.load( + trustStorePath.toString(), + password, + TlsTrustStoreTest.class + ) + ); + trustManager.checkServerTrusted( + new X509Certificate[]{loadCertificate("server.crt")}, + "RSA" + ); + try { + TlsTrustStore.load( + trustStorePath.toString(), + null, + TlsTrustStoreTest.class + ); + Assert.fail("expected a JKS file without its password to be rejected as invalid PEM"); + } catch (CertificateException e) { + Assert.assertTrue( + e.getMessage(), + e.getMessage().contains( + "if this is a JKS or PKCS#12 trust store, set tls_roots_password" + ) + ); + } + } finally { + Files.deleteIfExists(trustStorePath); + } + } + + @Test + public void testPemBundleLoadsEveryCertificate() throws Exception { + byte[] root = Files.readAllBytes(resourcePath("server-rootCA.pem")); + byte[] server = Files.readAllBytes(resourcePath("server.crt")); + byte[] bundle = new byte[root.length + server.length]; + System.arraycopy(root, 0, bundle, 0, root.length); + System.arraycopy(server, 0, bundle, root.length, server.length); + + Path bundlePath = Files.createTempFile("questdb-tls-roots", ".pem"); + try { + Files.write(bundlePath, bundle); + X509TrustManager trustManager = x509TrustManager( + TlsTrustStore.load( + bundlePath.toString(), + null, + TlsTrustStoreTest.class + ) + ); + Assert.assertEquals(2, trustManager.getAcceptedIssuers().length); + } finally { + Files.deleteIfExists(bundlePath); + } + } + + @Test + public void testPemClasspathRootTrustsServerCertificate() throws Exception { + X509TrustManager trustManager = x509TrustManager( + TlsTrustStore.load( + "classpath:" + RESOURCE_PREFIX + "server-rootCA.pem", + null, + TlsTrustStoreTest.class + ) + ); + trustManager.checkServerTrusted( + new X509Certificate[]{loadCertificate("server.crt")}, + "RSA" + ); + } + + @Test + public void testPemWithoutCertificatesIsRejected() throws Exception { + Path emptyPem = Files.createTempFile("questdb-empty-tls-roots", ".pem"); + try { + try { + TlsTrustStore.load( + emptyPem.toString(), + null, + TlsTrustStoreTest.class + ); + Assert.fail("expected an empty PEM file to be rejected"); + } catch (CertificateException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("no X.509 certificates")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains(emptyPem.toString())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("set tls_roots_password")); + } + } finally { + Files.deleteIfExists(emptyPem); + } + } + + @Test + public void testPkcs12TrustStoreStillSupported() throws Exception { + X509Certificate rootCertificate = loadCertificate("server-rootCA.pem"); + Path trustStorePath = Files.createTempFile("questdb-tls-roots", ".p12"); + char[] password = "questdb".toCharArray(); + try { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setCertificateEntry("test-root", rootCertificate); + try (OutputStream output = Files.newOutputStream(trustStorePath)) { + keyStore.store(output, password); + } + + X509TrustManager trustManager = x509TrustManager( + TlsTrustStore.load( + trustStorePath.toString(), + password, + TlsTrustStoreTest.class + ) + ); + trustManager.checkServerTrusted( + new X509Certificate[]{loadCertificate("server.crt")}, + "RSA" + ); + } finally { + Files.deleteIfExists(trustStorePath); + } + } + + private static X509Certificate loadCertificate(String resourceName) throws Exception { + try (InputStream input = TlsTrustStoreTest.class.getResourceAsStream(RESOURCE_PREFIX + resourceName)) { + Assert.assertNotNull("missing test certificate: " + resourceName, input); + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(input); + } + } + + private static Path resourcePath(String resourceName) throws Exception { + return Paths.get(TlsTrustStoreTest.class.getResource(RESOURCE_PREFIX + resourceName).toURI()); + } + + private static X509TrustManager x509TrustManager(TrustManager[] trustManagers) { + for (TrustManager trustManager : trustManagers) { + if (trustManager instanceof X509TrustManager) { + return (X509TrustManager) trustManager; + } + } + throw new AssertionError("no X509TrustManager was created"); + } +} diff --git a/core/src/test/resources/io/questdb/client/test/network/server-rootCA.pem b/core/src/test/resources/io/questdb/client/test/network/server-rootCA.pem new file mode 100644 index 00000000..6cc427ff --- /dev/null +++ b/core/src/test/resources/io/questdb/client/test/network/server-rootCA.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIUeU6Sa7S/zNCPRkHuS7mqtWwr5dQwDQYJKoZIhvcNAQEL +BQAwYTELMAkGA1UEBhMCR0IxEDAOBgNVBAoMB1F1ZXN0REIxGDAWBgNVBAsMD0ph +dmFDbGllbnRUZXN0czEmMCQGA1UEAwwdUXVlc3REQi1KYXZhLUNsaWVudC1UZXN0 +LVJvb3QwIBcNMjYwNzI4MDkxMDI0WhgPMjA1NjA3MjAwOTEwMjRaMGExCzAJBgNV +BAYTAkdCMRAwDgYDVQQKDAdRdWVzdERCMRgwFgYDVQQLDA9KYXZhQ2xpZW50VGVz +dHMxJjAkBgNVBAMMHVF1ZXN0REItSmF2YS1DbGllbnQtVGVzdC1Sb290MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1dzds1ktah54t8enP6N/r2PIpALc +RRIMJgfK21IF+MzM6rASRjaZadNYghecHW9q0H5/TnlSPTreEKSMBpbWGbjRccMu +1Sz91ZxqtyIi+j0kqjXjOAQ4NDPRjXCI4Ke3Y6FzmuYrag/zfyiB3OI6FRRbm98+ +Xt/1o8yCgw65Hnthm4UczSwU5jfirElnFo3sJcWVE9niNbIj4pkYMZS0d5c1c2jl +7pB+0iFCGQOY5BTRTC9WbK4St34FfPzP0qNSCAWAH2SVYbmyPDHNJDu8kUxuYkBU +1ZuouAFs4f443LkxHP/tyYZbJHfBN07WNwTmYxks+6H8bCxn1NDw6Oj87QIDAQAB +o2MwYTAdBgNVHQ4EFgQUdhNgA0wNT4wyDuFUGC0QL+znYhIwHwYDVR0jBBgwFoAU +dhNgA0wNT4wyDuFUGC0QL+znYhIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwDQYJKoZIhvcNAQELBQADggEBAKHl3VxuiTym+VqyIyfIQcN8gTA0g6qg +BVdH86i93nG0Y5t75dbzF3GRRQ0eYwrbhPJanZDUc3TWAPSP9Px4Me1zit+B4Lte +1wkb6xVXm5lan/pEOE13fdr8l6baB10PoWv5y9O61oD38PtqXyXPKaHL3wMQEwDE +OsyYAIc4hrg5KlF5nq2FEER/hFRicaMj8VQmz53vMi1UczdwWqcxU8+blXCQGXU2 +lZstcGUUBuFKHnK4fTYnCDmYCA+01I4lO38hajIAtsmq4SGs5HC+VLwbae/nY9N1 +SCXEuT7cFf4COsAVQT7WzeaqhjwHtFwLEw7uWNXldaqXxPKURR2D8S0= +-----END CERTIFICATE----- diff --git a/core/src/test/resources/io/questdb/client/test/network/server.crt b/core/src/test/resources/io/questdb/client/test/network/server.crt new file mode 100644 index 00000000..d59a73d1 --- /dev/null +++ b/core/src/test/resources/io/questdb/client/test/network/server.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDnDCCAoSgAwIBAgIUEEcdD6CPsozNVskK0rl7u3A66CEwDQYJKoZIhvcNAQEL +BQAwYTELMAkGA1UEBhMCR0IxEDAOBgNVBAoMB1F1ZXN0REIxGDAWBgNVBAsMD0ph +dmFDbGllbnRUZXN0czEmMCQGA1UEAwwdUXVlc3REQi1KYXZhLUNsaWVudC1UZXN0 +LVJvb3QwIBcNMjYwNzI4MDkxMDMzWhgPMjA1NjA3MjAwOTEwMzNaME0xCzAJBgNV +BAYTAkdCMRAwDgYDVQQKDAdRdWVzdERCMRgwFgYDVQQLDA9KYXZhQ2xpZW50VGVz +dHMxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAMaGPJ3a6xffxhlJfdc7WqVniYTIfgutBDXELZbv8/bYgMHvk42mxY4z +a1G5K+xBwFAzdkumH7nZOQuB80f+jfk/7mEfK3b15P/SPPQA6wNFlcLjC4dzocC2 +RpE4n6PbGmDq7RSOtSQfNV5+p0Z3gh2o4Uu3LW87S3Pe5gdUbDsXLOw8gZH1XAwl +HgFtoLSexPd0wCe/EU3sIgNQXtYpbwhtq0VU2eq1KMCzHHFI4Dy35P85aMSoILxw +aTWnQUEHAXDKo2+48DpXuOmv/X7ndP8YOLNVkt+SBGkc5WSELEWxPBa8Gf7L3rPR +I8MkyTbOEgt66DyRJGILu/YYnKkMSAsCAwEAAaNeMFwwGgYDVR0RBBMwEYIJbG9j +YWxob3N0hwR/AAABMB0GA1UdDgQWBBQEvXtQ9mDQEN2XR652wXB1uStc2DAfBgNV +HSMEGDAWgBR2E2ADTA1PjDIO4VQYLRAv7OdiEjANBgkqhkiG9w0BAQsFAAOCAQEA +x6pled7a+ztb+MARD8NCYeejGuD9ErV0iTGUhgTdPvOXY4RvGDmk7FlFjeO+kY9Z +TNLni7WZbYpLrnRl6Cz2zT1rxLfCwTXLgCpSza7t01V9Pltp3cRu8hvTNKIyKGUI +ltPf5YX2b7B+apatFmNhFtTOCp3s/nN/gc/+AKVZp0Q5A7Djqxf+mAEVeVTRmuVM +2khTRUFaElCbl1OlkR2wsCF0NSG6kJjPgikQzYn5VPA0QKdn8J3LxrIMhMnPiwag +vmLMZcDYMj8oUPxt0e6O3Vic12eWnwy8sFi64JyWFTx1jaKPe6Y5Ty2Ocd2A1ol+ +bWzlpt7e7EhpuP8If1VqEw== +-----END CERTIFICATE----- diff --git a/examples/src/main/java/com/example/sender/WsAuthTlsExample.java b/examples/src/main/java/com/example/sender/WsAuthTlsExample.java index b87b3982..281ed0d4 100644 --- a/examples/src/main/java/com/example/sender/WsAuthTlsExample.java +++ b/examples/src/main/java/com/example/sender/WsAuthTlsExample.java @@ -15,8 +15,9 @@ * (Enterprise). Mutually exclusive with {@code username}/{@code password}.

  • *
  • {@code username=...;password=...} -- HTTP basic auth; both halves * required together.
  • - *
  • {@code tls_roots=...;tls_roots_password=...} -- a custom truststore - * (e.g. a JKS) when the server cert isn't in the default trust store.
  • + *
  • {@code tls_roots=...} -- a PEM certificate or bundle when the server + * cert isn't in the default trust store. Add {@code tls_roots_password} + * when the file is a JKS or PKCS#12 trust store instead.
  • * * The {@code wss} schema turns on TLS. Contrast with the ILP TLS examples * ({@link AuthTlsExample}, {@link HttpsAuthExample}), which secure a dedicated @@ -26,13 +27,12 @@ public class WsAuthTlsExample { public static void main(String[] args) { - // Replace the address, token, and truststore path with your own. For + // Replace the address, token, and PEM roots path with your own. For // HTTP basic auth swap token= for username=...;password=... try (QuestDB db = QuestDB.connect( "wss::addr=db.example.com:9000;" + "token=YOUR_BEARER_TOKEN;" - + "tls_roots=/path/to/truststore.jks;" - + "tls_roots_password=changeit;")) { + + "tls_roots=/path/to/ca.pem;")) { try (Sender sender = db.borrowSender()) { sender.table("trades") @@ -49,5 +49,8 @@ public static void main(String[] args) { // production -- it defeats TLS entirely: // // QuestDB.connect("wss::addr=localhost:9000;tls_verify=unsafe_off;token=...;") + // + // Existing JKS and PKCS#12 trust stores remain supported by pairing + // tls_roots=/path/to/truststore.jks with tls_roots_password=changeit. } }