diff --git a/cli/cli.go b/cli/cli.go index fc96180..17eb847 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -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 { diff --git a/lib/verify.go b/lib/verify.go index 8587a5b..50be382 100644 --- a/lib/verify.go +++ b/lib/verify.go @@ -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 +}