mirror of
https://github.com/square/certigo.git
synced 2024-04-21 12:32:40 +00:00
Add support for verifying server certs against SPIFFE IDs (#252)
Add support for verifying server certs against SPIFFE IDs This adds support to "certigo connect" to verify that the server presented a certificate for a specific hostname or SPIFFE ID. This name can now be unrelated to the hostname to connect to and the Server Name Indication (SNI) to send to the server during the TLS handshake. Prior to this commit, the name expected in the certificate could only be a hostname, specified either via --name (SNI) or the hostname to connect to. SPIFFE IDs -- URLs with "spiffe" as the scheme -- are not useful as hostnames or SNIs. As a result, this commit adds a new --expected-name switch to certigo connect. This override provides full control over what name to expect in the server certificate presented during the TLS handshake. For example, this name can now be different from the hostname connected to and the name specified in Server Name Indication (SNI). Moreover, if --expected-name is a URL with "spiffe" scheme, it is only matched against Subject Alternative Names of type URI, as per the SPIFFE spec.
This commit is contained in:
+27
-19
@@ -25,19 +25,20 @@ var (
|
||||
dumpPem = dump.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool()
|
||||
dumpJSON = dump.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool()
|
||||
|
||||
connect = app.Command("connect", "Connect to a server and print its certificate(s).")
|
||||
connectTo = connect.Arg("server[:port]", "Hostname or IP to connect to, with optional port.").Required().String()
|
||||
connectName = connect.Flag("name", "Override the server name used for Server Name Indication (SNI).").Short('n').String()
|
||||
connectCaPath = connect.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile()
|
||||
connectCert = connect.Flag("cert", "Client certificate chain for connecting to server (PEM).").ExistingFile()
|
||||
connectKey = connect.Flag("key", "Private key for client certificate, if not in same file (PEM).").ExistingFile()
|
||||
connectStartTLS = connect.Flag("start-tls", fmt.Sprintf("Enable StartTLS protocol; one of: %v.", starttls.Protocols)).Short('t').PlaceHolder("PROTOCOL").Enum(starttls.Protocols...)
|
||||
connectIdentity = connect.Flag("identity", "With --start-tls, sets the DB user or SMTP EHLO name").Default("certigo").String()
|
||||
connectProxy = connect.Flag("proxy", "Optional URI for HTTP(s) CONNECT proxy to dial connections with").URL()
|
||||
connectTimeout = connect.Flag("timeout", "Timeout for connecting to remote server (can be '5m', '1s', etc).").Default("5s").Duration()
|
||||
connectPem = connect.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool()
|
||||
connectJSON = connect.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool()
|
||||
connectVerify = connect.Flag("verify", "Verify certificate chain.").Bool()
|
||||
connect = app.Command("connect", "Connect to a server and print its certificate(s).")
|
||||
connectTo = connect.Arg("server[:port]", "Hostname or IP to connect to, with optional port.").Required().String()
|
||||
connectName = connect.Flag("name", "Override the server name used for Server Name Indication (SNI).").Short('n').String()
|
||||
connectCaPath = connect.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile()
|
||||
connectCert = connect.Flag("cert", "Client certificate chain for connecting to server (PEM).").ExistingFile()
|
||||
connectKey = connect.Flag("key", "Private key for client certificate, if not in same file (PEM).").ExistingFile()
|
||||
connectStartTLS = connect.Flag("start-tls", fmt.Sprintf("Enable StartTLS protocol; one of: %v.", starttls.Protocols)).Short('t').PlaceHolder("PROTOCOL").Enum(starttls.Protocols...)
|
||||
connectIdentity = connect.Flag("identity", "With --start-tls, sets the DB user or SMTP EHLO name").Default("certigo").String()
|
||||
connectProxy = connect.Flag("proxy", "Optional URI for HTTP(s) CONNECT proxy to dial connections with").URL()
|
||||
connectTimeout = connect.Flag("timeout", "Timeout for connecting to remote server (can be '5m', '1s', etc).").Default("5s").Duration()
|
||||
connectPem = connect.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool()
|
||||
connectJSON = connect.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool()
|
||||
connectVerify = connect.Flag("verify", "Verify certificate chain.").Bool()
|
||||
connectVerifyExpectedName = connect.Flag("expected-name", "Name expected in the server TLS certificate. Defaults to name from SNI or, if SNI not overridden, the hostname to connect to.").String()
|
||||
|
||||
verify = app.Command("verify", "Verify a certificate chain from file/stdin against a name.")
|
||||
verifyFile = verify.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFile()
|
||||
@@ -145,13 +146,20 @@ func Run(args []string, tty terminal.Terminal) int {
|
||||
}
|
||||
}
|
||||
|
||||
var hostname string
|
||||
if *connectName != "" {
|
||||
hostname = *connectName
|
||||
} else {
|
||||
hostname = strings.Split(*connectTo, ":")[0]
|
||||
// Determine what name the server's certificate should match
|
||||
var expectedNameInCertificate string
|
||||
switch {
|
||||
case *connectVerifyExpectedName != "":
|
||||
// Use the explicitly provided name
|
||||
expectedNameInCertificate = *connectVerifyExpectedName
|
||||
case *connectName != "":
|
||||
// Use the provided SNI
|
||||
expectedNameInCertificate = *connectName
|
||||
default:
|
||||
// Use the hostname/IP from the connect string
|
||||
expectedNameInCertificate = strings.Split(*connectTo, ":")[0]
|
||||
}
|
||||
verifyResult := lib.VerifyChain(connState.PeerCertificates, connState.OCSPResponse, hostname, *connectCaPath)
|
||||
verifyResult := lib.VerifyChain(connState.PeerCertificates, connState.OCSPResponse, expectedNameInCertificate, *connectCaPath)
|
||||
result.VerifyResult = &verifyResult
|
||||
|
||||
if *connectJSON {
|
||||
|
||||
+75
-2
@@ -23,6 +23,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto/tls"
|
||||
@@ -109,7 +110,7 @@ func caBundle(caPath string) (*x509.CertPool, error) {
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath string) SimpleVerification {
|
||||
func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, expectedName, caPath string) SimpleVerification {
|
||||
result := SimpleVerification{
|
||||
Chains: [][]simpleVerifyCert{},
|
||||
OCSPWasStapled: ocspStaple != nil,
|
||||
@@ -130,8 +131,17 @@ func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath s
|
||||
result.Error = fmt.Sprintf("%s", err)
|
||||
return result
|
||||
}
|
||||
// expectedName could be a hostname or could be a SPIFFE ID (spiffe://...)
|
||||
// x509 package doesn't support verifying SPIFFE IDs. When we're expecting a SPIFFE ID, we tell
|
||||
// Certificate.Verify below to skip name matching, and then we perform our own matching later
|
||||
// on.
|
||||
spiffeIDExpected := strings.HasPrefix(strings.ToLower(expectedName), "spiffe://")
|
||||
var expectedDNSName string
|
||||
if !spiffeIDExpected {
|
||||
expectedDNSName = expectedName
|
||||
}
|
||||
opts := x509.VerifyOptions{
|
||||
DNSName: dnsName,
|
||||
DNSName: expectedDNSName,
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
}
|
||||
@@ -142,6 +152,16 @@ func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath s
|
||||
return result
|
||||
}
|
||||
|
||||
if spiffeIDExpected {
|
||||
// The Verify method above didn't actually verify that the certificate matches the expected
|
||||
// SPIFFE ID. We thus perform this check explicitly here.
|
||||
err = verifyCertificateSPIFFEIDMatch(certs[0], expectedName)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("%s", err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
for _, chain := range chains {
|
||||
status, err := checkOCSP(chain, ocspStaple)
|
||||
if err == nil {
|
||||
@@ -243,3 +263,56 @@ func printOCSPStatus(out io.Writer, result SimpleVerification) {
|
||||
fmt.Fprintf(out, "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func verifyCertificateSPIFFEIDMatch(cert *x509.Certificate, expectedSPIFFEID string) error {
|
||||
// Based on https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md
|
||||
uris := cert.URIs
|
||||
if len(uris) == 0 {
|
||||
return fmt.Errorf("x509: cannot validate certificate for %s, because it contains no URI SANs", expectedSPIFFEID)
|
||||
} else if len(uris) > 1 {
|
||||
return fmt.Errorf("x509: cannot validate certificate for %s, because it contains %d URIs SANs instead of exactly 1", expectedSPIFFEID, len(uris))
|
||||
}
|
||||
|
||||
uri := uris[0]
|
||||
actualSPIFFEID := uri.String()
|
||||
err := verifySPIFFEIDMatch(expectedSPIFFEID, actualSPIFFEID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("x509: certificate is valid for %s, not %s: %s", actualSPIFFEID, expectedSPIFFEID, err)
|
||||
}
|
||||
|
||||
// Matched
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifySPIFFEIDMatch(expected string, actual string) error {
|
||||
// Based on https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md
|
||||
// SPIFFE ID format: spiffe://trust-domain-name/path
|
||||
// * scheme and authority of the URI are case-insensitive
|
||||
// * path of the URI is case-sensitive
|
||||
//
|
||||
// However, as per Trust Domain and Path sections, authority is not supposed to include userinfo
|
||||
// and authority's host is supposed to be lower-case and no %-escaped characters. Similarly, no
|
||||
// %-escaped stuff in the path.
|
||||
//
|
||||
// Thus, all we need to do is verify that:
|
||||
// 1. both IDs start with "spiffe://" (case-insensitive)
|
||||
// 2. both IDs equal (case-sensitive) after the "spiffe://"
|
||||
|
||||
// Verify both have "spiffe" as the scheme (case-insensitive)
|
||||
if !strings.HasPrefix(strings.ToLower(expected), "spiffe://") {
|
||||
return fmt.Errorf("Expected scheme is not \"spiffe\"")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(actual), "spiffe://") {
|
||||
return fmt.Errorf("Actual scheme is not \"spiffe\"")
|
||||
}
|
||||
|
||||
// Verify that everything after the scheme equals in a case-sensitive way
|
||||
expectedRemainder := expected[len("spiffe://"):]
|
||||
actualRemainder := actual[len("spiffe://"):]
|
||||
if expectedRemainder != actualRemainder {
|
||||
return fmt.Errorf("Trust domain and/or path do not match")
|
||||
}
|
||||
|
||||
// They match
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user