From ea20a04985fd60217bf3e893501424f339a4c4fe Mon Sep 17 00:00:00 2001 From: Darpan Adhikari Date: Sun, 20 Sep 2026 00:53:35 +0545 Subject: [PATCH] Add client certificate flags to step certificate inspect Allow step certificate inspect to present a client certificate and key when connecting to a remote server, enabling inspection of certificates served by mTLS-protected endpoints. The --cert and --key flags must be provided together. Fixes #65 --- command/certificate/inspect.go | 38 +++++++- command/certificate/remote.go | 13 ++- command/certificate/remote_test.go | 138 +++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 4 deletions(-) diff --git a/command/certificate/inspect.go b/command/certificate/inspect.go index 2b273ac8..17dae91b 100644 --- a/command/certificate/inspect.go +++ b/command/certificate/inspect.go @@ -1,6 +1,7 @@ package certificate import ( + "crypto/tls" "crypto/x509" "encoding/json" "encoding/pem" @@ -27,7 +28,7 @@ func inspectCommand() cli.Command { Usage: `print certificate or CSR details in human readable format`, UsageText: `**step certificate inspect** [**--bundle**] [**--short**] [**--format**=] [**--roots**=] -[**--servername**=]`, +[**--servername**=] [**--cert**=] [**--key**=]`, Description: `**step certificate inspect** prints the details of the certificate or CSR in a human- or machine-readable format. Beware: Local certificates are never verified. Always verify a certificate (using **step certificate verify**) @@ -105,6 +106,12 @@ $ step certificate inspect https://smallstep.com \ --roots "./path/to/root/certificates/" ''' +Inspect a remote certificate on an mTLS-enabled server, using a client certificate to authenticate: +''' +$ step certificate inspect https://service.example.com \ +--cert ./client.crt --key ./client.key +''' + Inspect a remote certificate chain in json format using a custom directory of root certificates to verify the server: ''' @@ -159,6 +166,17 @@ authenticity of the remote server. **directory** : Relative or full path to a directory. Every PEM encoded certificate from each file in the directory will be used for path validation.`, + }, + cli.StringFlag{ + Name: "cert", + Usage: `The containing the client certificate that will be used to +authenticate to the remote server. The --cert flag must be used together with the +--key flag.`, + }, + cli.StringFlag{ + Name: "key", + Usage: `The containing the private key that will be used to authenticate +to the remote server. The --key flag must be used together with the --cert flag.`, }, flags.ServerName, cli.BoolFlag{ @@ -192,6 +210,8 @@ func inspectAction(ctx *cli.Context) error { format = ctx.String("format") roots = ctx.String("roots") serverName = ctx.String("servername") + certFile = ctx.String("cert") + keyFile = ctx.String("key") short = ctx.Bool("short") insecure = ctx.Bool("insecure") ) @@ -207,12 +227,26 @@ func inspectAction(ctx *cli.Context) error { if short && (format == "json" || format == "pem") { return errs.IncompatibleFlagWithFlag(ctx, "short", "format "+format) } + if (certFile == "") != (keyFile == "") { + if certFile == "" { + return errs.RequiredWithFlag(ctx, "key", "cert") + } + return errs.RequiredWithFlag(ctx, "cert", "key") + } switch addr, isURL, err := trimURL(crtFile); { case err != nil: return err case isURL: - peerCertificates, err := getPeerCertificates(addr, serverName, roots, insecure) + var clientCerts []tls.Certificate + if certFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return errors.Wrapf(err, "error loading the client certificate and key") + } + clientCerts = []tls.Certificate{cert} + } + peerCertificates, err := getPeerCertificatesWithClientCert(addr, serverName, roots, insecure, clientCerts) if err != nil { return err } diff --git a/command/certificate/remote.go b/command/certificate/remote.go index 211de504..63e7428f 100644 --- a/command/certificate/remote.go +++ b/command/certificate/remote.go @@ -33,6 +33,14 @@ var urlPrefixes = map[string]uint16{ // *insecure*: do not verify that the server's certificate has been signed by // a trusted root func getPeerCertificates(addr, serverName, roots string, insecure bool) ([]*x509.Certificate, error) { + return getPeerCertificatesWithClientCert(addr, serverName, roots, insecure, nil) +} + +// getPeerCertificatesWithClientCert is like getPeerCertificates but also +// presents the given client certificates to the server for TLS client +// authentication, enabling inspection of certificates served on mTLS-protected +// endpoints. +func getPeerCertificatesWithClientCert(addr, serverName, roots string, insecure bool, clientCerts []tls.Certificate) ([]*x509.Certificate, error) { var ( err error rootCAs *x509.CertPool @@ -47,8 +55,9 @@ func getPeerCertificates(addr, serverName, roots string, insecure bool) ([]*x509 addr = net.JoinHostPort(addr, "443") } tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: rootCAs, + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + Certificates: clientCerts, } if insecure { tlsConfig.InsecureSkipVerify = true diff --git a/command/certificate/remote_test.go b/command/certificate/remote_test.go index 91037745..ec5e52de 100644 --- a/command/certificate/remote_test.go +++ b/command/certificate/remote_test.go @@ -1,9 +1,20 @@ package certificate import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "errors" + "math/big" "net" + "os" + "path/filepath" "testing" + "time" "github.com/smallstep/assert" ) @@ -82,3 +93,130 @@ func TestGetPeerCertificateServerName(t *testing.T) { }) } } + +type testCert struct { + cert *x509.Certificate + key *ecdsa.PrivateKey +} + +func newSelfSignedCA(t *testing.T) testCert { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + assert.FatalError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + assert.FatalError(t, err) + cert, err := x509.ParseCertificate(der) + assert.FatalError(t, err) + return testCert{cert: cert, key: key} +} + +func newSignedCert(t *testing.T, ca testCert, cn string, ips []net.IP) testCert { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + assert.FatalError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + IPAddresses: ips, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key) + assert.FatalError(t, err) + cert, err := x509.ParseCertificate(der) + assert.FatalError(t, err) + return testCert{cert: cert, key: key} +} + +func pemCert(der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func pemECKey(t *testing.T, key *ecdsa.PrivateKey) []byte { + t.Helper() + der, err := x509.MarshalECPrivateKey(key) + assert.FatalError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) +} + +func TestGetPeerCertificateClientCert(t *testing.T) { + ca := newSelfSignedCA(t) + otherCA := newSelfSignedCA(t) + serverCert := newSignedCert(t, ca, "server", []net.IP{net.ParseIP("127.0.0.1")}) + clientCert := newSignedCert(t, ca, "client", nil) + mismatchedClientCert := newSignedCert(t, otherCA, "mismatched-client", nil) + + serverTLS, err := tls.X509KeyPair(pemCert(serverCert.cert.Raw), pemECKey(t, serverCert.key)) + assert.FatalError(t, err) + clientTLSPair, err := tls.X509KeyPair(pemCert(clientCert.cert.Raw), pemECKey(t, clientCert.key)) + assert.FatalError(t, err) + mismatchedPair, err := tls.X509KeyPair(pemCert(mismatchedClientCert.cert.Raw), pemECKey(t, mismatchedClientCert.key)) + assert.FatalError(t, err) + + rootCAs := x509.NewCertPool() + rootCAs.AddCert(ca.cert) + rootsFile := filepath.Join(t.TempDir(), "roots.pem") + assert.FatalError(t, os.WriteFile(rootsFile, pemCert(ca.cert.Raw), 0o600)) + + // serverHandshake returns the result of the TLS handshake as seen by the + // server. Because a Go TLS 1.3 client can complete its side of the + // handshake before receiving a rejection alert, the authoritative check + // for whether the presented client certificate was accepted is the + // server-side handshake result. + serverHandshake := func(t *testing.T, clientCerts []tls.Certificate) ([]*x509.Certificate, error, error) { + t.Helper() + l, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{serverTLS}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: rootCAs, + }) + assert.FatalError(t, err) + defer l.Close() + + result := make(chan error, 1) + go func() { + c, err := l.Accept() + if err != nil { + result <- err + return + } + result <- c.(*tls.Conn).Handshake() + _ = c.Close() + }() + + peerCerts, dialErr := getPeerCertificatesWithClientCert(l.Addr().String(), "", rootsFile, false, clientCerts) + return peerCerts, dialErr, <-result + } + + t.Run("with-client-cert", func(t *testing.T) { + peerCerts, dialErr, serverErr := serverHandshake(t, []tls.Certificate{clientTLSPair}) + if dialErr != nil { + t.Fatalf("expected successful client dial with client cert, got %v", dialErr) + } + if serverErr != nil { + t.Fatalf("expected server to accept client cert, got %v", serverErr) + } + if len(peerCerts) == 0 { + t.Fatal("expected server peer certificates in response") + } + }) + + t.Run("with-mismatched-client-cert", func(t *testing.T) { + _, _, serverErr := serverHandshake(t, []tls.Certificate{mismatchedPair}) + if serverErr == nil { + t.Fatal("expected server to reject a client cert it does not trust") + } + }) +}