feat(client-java): accept gateway cert by default over TLS

This commit is contained in:
Joseph Doherty
2026-06-01 07:13:45 -04:00
parent 572b268d81
commit a276f46f81
5 changed files with 257 additions and 2 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ subprojects {
pluginManager.withPlugin('java') {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(26)
}
}
@@ -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 extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
@@ -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.
*
@@ -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());
}
}
@@ -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.
*
* <p>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<OpenSessionReply> responseObserver) {
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("tls-test-session")
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build())
.build());
responseObserver.onCompleted();
}
}
}