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()) + } +}