Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions command/certificate/inspect.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package certificate

import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
Expand All @@ -27,7 +28,7 @@ func inspectCommand() cli.Command {
Usage: `print certificate or CSR details in human readable format`,
UsageText: `**step certificate inspect** <crt-file>
[**--bundle**] [**--short**] [**--format**=<format>] [**--roots**=<root-bundle>]
[**--servername**=<servername>]`,
[**--servername**=<servername>] [**--cert**=<cert-file>] [**--key**=<key-file>]`,
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**)
Expand Down Expand Up @@ -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:
'''
Expand Down Expand Up @@ -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 <file> 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 <file> 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{
Expand Down Expand Up @@ -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")
)
Expand All @@ -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
}
Expand Down
13 changes: 11 additions & 2 deletions command/certificate/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
138 changes: 138 additions & 0 deletions command/certificate/remote_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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")
}
})
}
Loading