From 574369b666b24cf943b62eb0c38237c8bb74b7d1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 6 Nov 2026 16:10:39 -0500 Subject: [PATCH] feat(mxgw-go): add -require-certificate-validation for strict TLS (#199) Over TLS without -ca-cert the CLI was always skip-verify, with no way to ask for verification against the system roots even though the library supports it. The new common flag is wired to Options.RequireCertificateValidation in both the gateway and Galaxy dialers and echoed in the redacted JSON options. Test: tls_flag_test.go starts a self-signed TLS gRPC gateway; the default open-session succeeds (positive control) and the same call with the flag fails to dial (fails on the old code with 'flag provided but not defined'). --- clients/go/README.md | 6 +- clients/go/cmd/mxgw-go/main.go | 32 +++++--- clients/go/cmd/mxgw-go/tls_flag_test.go | 105 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 clients/go/cmd/mxgw-go/tls_flag_test.go diff --git a/clients/go/README.md b/clients/go/README.md index b196c16..4d68c09 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -432,7 +432,11 @@ the gateway: Every subcommand wired into the CLI. All accept the common flags (`-endpoint`, `-plaintext`, `-api-key` / `-api-key-env`, `-ca-cert`, -`-server-name-override`, `-call-timeout`) and most accept `-json`. +`-server-name-override`, `-call-timeout`, `-require-certificate-validation`) and +most accept `-json`. Over TLS without `-ca-cert` the CLI accepts any gateway +certificate (the self-signed default); `-require-certificate-validation` sets +`Options.RequireCertificateValidation` so the certificate must chain to the +system trust roots instead. | Command | Purpose | |---|---| diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index 90e1d38..fa93ef1 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -40,6 +40,10 @@ type commonOptions struct { CACertFile string `json:"caCertFile,omitempty"` ServerName string `json:"serverNameOverride,omitempty"` CallTimeout string `json:"callTimeout,omitempty"` + // RequireCertificateValidation verifies the gateway certificate against the + // system trust roots when no -ca-cert is pinned, instead of the lenient + // self-signed default. + RequireCertificateValidation bool `json:"requireCertificateValidation,omitempty"` apiKeyValue string timeout time.Duration @@ -1297,6 +1301,8 @@ func bindCommonFlags(flags *flag.FlagSet) *commonOptions { flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file") flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override") flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout") + flags.BoolVar(&common.RequireCertificateValidation, "require-certificate-validation", false, + "verify the gateway TLS certificate against the system trust roots when no -ca-cert is set (default: accept any certificate)") return common } @@ -1307,12 +1313,13 @@ func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Clie } client, err := mxgateway.Dial(ctx, mxgateway.Options{ - Endpoint: options.Endpoint, - APIKey: options.apiKeyValue, - Plaintext: options.Plaintext, - CACertFile: options.CACertFile, - ServerNameOverride: options.ServerName, - CallTimeout: options.timeout, + Endpoint: options.Endpoint, + APIKey: options.apiKeyValue, + Plaintext: options.Plaintext, + CACertFile: options.CACertFile, + ServerNameOverride: options.ServerName, + CallTimeout: options.timeout, + RequireCertificateValidation: options.RequireCertificateValidation, }) return client, options, err } @@ -1523,12 +1530,13 @@ func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgatewa } client, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{ - Endpoint: options.Endpoint, - APIKey: options.apiKeyValue, - Plaintext: options.Plaintext, - CACertFile: options.CACertFile, - ServerNameOverride: options.ServerName, - CallTimeout: options.timeout, + Endpoint: options.Endpoint, + APIKey: options.apiKeyValue, + Plaintext: options.Plaintext, + CACertFile: options.CACertFile, + ServerNameOverride: options.ServerName, + CallTimeout: options.timeout, + RequireCertificateValidation: options.RequireCertificateValidation, }) return client, options, err } diff --git a/clients/go/cmd/mxgw-go/tls_flag_test.go b/clients/go/cmd/mxgw-go/tls_flag_test.go new file mode 100644 index 0000000..6f993ec --- /dev/null +++ b/clients/go/cmd/mxgw-go/tls_flag_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "strings" + "testing" + "time" + + pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// #199 — `--tls` without a CA used to be skip-verify with no way out from the +// CLI. -require-certificate-validation must make the CLI reject a certificate +// the system roots do not trust (the gateway's own self-signed one). + +type openSessionFakeGateway struct { + pb.UnimplementedMxAccessGatewayServer +} + +func (g *openSessionFakeGateway) OpenSession(context.Context, *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) { + return &pb.OpenSessionReply{ + SessionId: "tls-session", + ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK}, + }, nil +} + +func startSelfSignedTLSGateway(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }))) + pb.RegisterMxAccessGatewayServer(server, &openSessionFakeGateway{}) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + return listener.Addr().String() +} + +func TestRequireCertificateValidationFlagRejectsSelfSignedGateway(t *testing.T) { + endpoint := startSelfSignedTLSGateway(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + base := []string{"open-session", "-endpoint", endpoint, "-api-key", "test", "-call-timeout", "5s"} + + // Positive control: the lenient default accepts the self-signed certificate. + var okOut, okErr bytes.Buffer + if err := runWithIO(ctx, base, &okOut, &okErr); err != nil { + t.Fatalf("default TLS open-session error = %v; stderr = %s", err, okErr.String()) + } + if got := strings.TrimSpace(okOut.String()); got != "tls-session" { + t.Fatalf("default TLS open-session output = %q", got) + } + + // Strict: the handshake never verifies, so the connected-dial wait runs out. + // The dial error (not a flag-parse error) is what proves the flag reached the + // TLS configuration; the positive control above proves the endpoint is live. + strictCtx, strictCancel := context.WithTimeout(t.Context(), 2*time.Second) + defer strictCancel() + var strictOut, strictErr bytes.Buffer + err := runWithIO(strictCtx, append(append([]string{}, base...), "-require-certificate-validation"), &strictOut, &strictErr) + if err == nil { + t.Fatalf("strict TLS open-session succeeded against a self-signed gateway; stdout = %s", strictOut.String()) + } + if !strings.Contains(err.Error(), "dial failed") { + t.Fatalf("strict TLS open-session error = %v; want a dial failure (stderr = %s)", err, strictErr.String()) + } +}