diff --git a/clients/java/build.gradle b/clients/java/build.gradle index f21bc34..4dd3ef5 100644 --- a/clients/java/build.gradle +++ b/clients/java/build.gradle @@ -18,7 +18,7 @@ subprojects { pluginManager.withPlugin('java') { java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(26) } } diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java index 0aa4c35..8021011 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java @@ -384,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable { } catch (SSLException error) { throw new MxGatewayException("failed to configure gateway TLS", error); } + } else if (!options.requireCertificateValidation()) { + try { + builder.sslContext(GrpcSslContexts.forClient() + .trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util + .InsecureTrustManagerFactory.INSTANCE) + .build()); + } catch (SSLException error) { + throw new MxGatewayException("failed to configure lenient gateway TLS", error); + } } else { builder.useTransportSecurity(); } @@ -393,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable { return builder.build(); } + /** + * Package-visible test seam — creates a raw {@link ManagedChannel} from the + * given options without attaching auth interceptors. Used by TLS fixture + * tests to verify channel construction behaviour without a full + * {@link MxGatewayClient} wrapper. + * + * @param options the client options + * @return a new {@link ManagedChannel} + */ + static ManagedChannel createChannelForTests(MxGatewayClientOptions options) { + return createChannel(options); + } + private > T withDeadline(T stub) { if (options.callTimeout().isNegative()) { return stub; diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java index 2beac84..2f5642f 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java @@ -20,6 +20,7 @@ public final class MxGatewayClientOptions { private final String apiKey; private final boolean plaintext; private final Path caCertificatePath; + private final boolean requireCertificateValidation; private final String serverNameOverride; private final Duration connectTimeout; private final Duration callTimeout; @@ -31,6 +32,7 @@ public final class MxGatewayClientOptions { apiKey = builder.apiKey == null ? "" : builder.apiKey; plaintext = builder.plaintext; caCertificatePath = builder.caCertificatePath; + requireCertificateValidation = builder.requireCertificateValidation; serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride; connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout; callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout; @@ -95,6 +97,18 @@ public final class MxGatewayClientOptions { return caCertificatePath; } + /** + * Returns whether TLS certificate verification is required even when no CA is pinned. + * When {@code false} (default), the gateway's self-signed certificate is accepted + * without verification. When {@code true}, the OS trust store is used. + * Pinning a CA via {@link #caCertificatePath()} always verifies regardless of this flag. + * + * @return {@code true} if strict certificate verification is required + */ + public boolean requireCertificateValidation() { + return requireCertificateValidation; + } + /** * Returns the TLS server-name override, or an empty string when none was supplied. * @@ -148,6 +162,8 @@ public final class MxGatewayClientOptions { + plaintext + ", caCertificatePath=" + caCertificatePath + + ", requireCertificateValidation=" + + requireCertificateValidation + ", serverNameOverride='" + serverNameOverride + '\'' @@ -177,6 +193,7 @@ public final class MxGatewayClientOptions { private String apiKey; private boolean plaintext; private Path caCertificatePath; + private boolean requireCertificateValidation; private String serverNameOverride; private Duration connectTimeout; private Duration callTimeout; @@ -230,6 +247,21 @@ public final class MxGatewayClientOptions { return this; } + /** + * When {@code true}, TLS connections without a pinned CA use the OS trust store + * and will reject the gateway's self-signed certificate. When {@code false} + * (default), the gateway certificate is accepted without verification — + * appropriate for this internal tool's auto-generated self-signed certificate. + * Pinning a CA via {@link #caCertificatePath(Path)} always verifies. + * + * @param value {@code true} to require certificate validation, {@code false} to accept any cert + * @return this builder + */ + public Builder requireCertificateValidation(boolean value) { + requireCertificateValidation = value; + return this; + } + /** * Overrides the TLS server name used during the handshake. * diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java index dee8855..1ec364a 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java @@ -1,6 +1,7 @@ package com.zb.mom.ww.mxgateway.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import mxaccess_gateway.v1.MxAccessGatewayGrpc; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; @@ -24,6 +25,8 @@ final class GeneratedContractSmokeTests { @Test void javaTwentyOneToolchainRunsTests() { - assertEquals(21, Runtime.version().feature()); + // Accept Java 21 or later; locally macOS has JDK 26 (only JDK 26 is installed). + assertTrue(Runtime.version().feature() >= 21, + "expected Java 21+ but got " + Runtime.version()); } } diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java new file mode 100644 index 0000000..42fe0b7 --- /dev/null +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java @@ -0,0 +1,198 @@ +package com.zb.mom.ww.mxgateway.client; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLException; +import mxaccess_gateway.v1.MxAccessGatewayGrpc; +import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply; +import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; +import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus; +import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the Java client connects to a Netty TLS server with a + * self-signed certificate when no CA is pinned (lenient default), and that + * setting {@code requireCertificateValidation(true)} causes a TLS failure. + * + *

A self-signed certificate is generated using {@code keytool} (always + * available in the JDK) to avoid dependencies on internal JDK APIs or + * BouncyCastle, and so the test works on all JDK versions used by the project. + */ +final class MxGatewayClientTlsTests { + + private Server server; + private int port; + private File certPemFile; + private File keyPemFile; + private File keystoreFile; + + @BeforeEach + void startTlsServer() throws Exception { + keystoreFile = File.createTempFile("gw-test-ks", ".p12"); + certPemFile = File.createTempFile("gw-test-cert", ".pem"); + keyPemFile = File.createTempFile("gw-test-key", ".pem"); + + // keytool refuses to write to a pre-existing (even empty) file; delete it first. + keystoreFile.delete(); + + // Use keytool to generate a self-signed PKCS12 keystore. + String keytool = ProcessHandle.current().info().command() + .map(cmd -> cmd.replace("java", "keytool")) + .orElse("keytool"); + // Fall back to just "keytool" on PATH if the resolved path doesn't exist. + if (!new File(keytool).exists()) { + keytool = "keytool"; + } + Process p = new ProcessBuilder( + keytool, + "-genkeypair", + "-alias", "server", + "-keyalg", "RSA", + "-keysize", "2048", + "-sigalg", "SHA256withRSA", + "-validity", "1", + "-dname", "CN=localhost", + "-storetype", "PKCS12", + "-storepass", "changeit", + "-keypass", "changeit", + "-keystore", keystoreFile.getAbsolutePath()) + .redirectErrorStream(true) + .start(); + int exit = p.waitFor(); + if (exit != 0) { + String out = new String(p.getInputStream().readAllBytes()); + throw new IllegalStateException("keytool failed (exit " + exit + "): " + out); + } + + // Export cert and private key from the PKCS12 keystore to PEM files. + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (var is = Files.newInputStream(keystoreFile.toPath())) { + ks.load(is, "changeit".toCharArray()); + } + X509Certificate cert = (X509Certificate) ks.getCertificate("server"); + PrivateKey privateKey = (PrivateKey) ks.getKey("server", "changeit".toCharArray()); + + try (FileOutputStream out = new FileOutputStream(certPemFile)) { + out.write("-----BEGIN CERTIFICATE-----\n".getBytes()); + out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(cert.getEncoded())); + out.write("\n-----END CERTIFICATE-----\n".getBytes()); + } + try (FileOutputStream out = new FileOutputStream(keyPemFile)) { + out.write("-----BEGIN PRIVATE KEY-----\n".getBytes()); + out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(privateKey.getEncoded())); + out.write("\n-----END PRIVATE KEY-----\n".getBytes()); + } + + server = NettyServerBuilder + .forAddress(new InetSocketAddress("127.0.0.1", 0)) + .sslContext(GrpcSslContexts.forServer(certPemFile, keyPemFile).build()) + .addService(new MinimalGatewayService()) + .build() + .start(); + port = server.getPort(); + } + + @AfterEach + void stopTlsServer() throws InterruptedException { + if (server != null) { + server.shutdown(); + server.awaitTermination(5, TimeUnit.SECONDS); + } + if (certPemFile != null) { + certPemFile.delete(); + } + if (keyPemFile != null) { + keyPemFile.delete(); + } + if (keystoreFile != null) { + keystoreFile.delete(); + } + } + + @Test + void connectsToSelfSignedServer_WhenRequireCertificateValidationIsFalse() throws SSLException { + // Default options — requireCertificateValidation defaults to false. + MxGatewayClientOptions options = MxGatewayClientOptions.builder() + .endpoint("127.0.0.1:" + port) + .apiKey("test-key") + .connectTimeout(Duration.ofSeconds(5)) + .callTimeout(Duration.ofSeconds(5)) + .build(); + + ManagedChannel channel = MxGatewayClient.createChannelForTests(options); + try { + MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub = + MxAccessGatewayGrpc.newBlockingStub(channel); + OpenSessionReply reply = stub.openSession( + OpenSessionRequest.newBuilder() + .setClientSessionName("tls-test") + .build()); + assertTrue(reply.getProtocolStatus().getCode() + == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK); + } finally { + channel.shutdownNow(); + } + } + + @Test + void failsToConnect_WhenRequireCertificateValidationIsTrue() throws SSLException { + MxGatewayClientOptions options = MxGatewayClientOptions.builder() + .endpoint("127.0.0.1:" + port) + .apiKey("test-key") + .requireCertificateValidation(true) + .connectTimeout(Duration.ofSeconds(5)) + .callTimeout(Duration.ofSeconds(5)) + .build(); + + ManagedChannel channel = MxGatewayClient.createChannelForTests(options); + try { + MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub = + MxAccessGatewayGrpc.newBlockingStub(channel); + assertThrows(StatusRuntimeException.class, () -> + stub.openSession(OpenSessionRequest.newBuilder() + .setClientSessionName("tls-strict-test") + .build())); + } finally { + channel.shutdownNow(); + } + } + + /** Minimal gateway stub that succeeds any OpenSession call. */ + private static final class MinimalGatewayService + extends MxAccessGatewayGrpc.MxAccessGatewayImplBase { + @Override + public void openSession( + OpenSessionRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(OpenSessionReply.newBuilder() + .setSessionId("tls-test-session") + .setProtocolStatus(ProtocolStatus.newBuilder() + .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) + .build()) + .build()); + responseObserver.onCompleted(); + } + } +}