cert-checker: add support for ipAddress SANs (#8188)

In cert-checker, inspect both the DNS Names and the IP Addresses
contained within the certificate being examined. Also add a check that
no other kinds of SANs exist in the certificate.

Fixes https://github.com/letsencrypt/boulder/issues/8183
This commit is contained in:
Aaron Gable 2025-05-16 16:22:56 -07:00 committed by GitHub
parent aaaf623d49
commit ac2dae70f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 175 additions and 142 deletions

View File

@ -8,6 +8,7 @@ import (
"encoding/json"
"flag"
"fmt"
"net/netip"
"os"
"regexp"
"slices"
@ -78,7 +79,7 @@ func (r *report) dump() error {
type reportEntry struct {
Valid bool `json:"valid"`
DNSNames []string `json:"dnsNames"`
SANs []string `json:"sans"`
Problems []string `json:"problems,omitempty"`
}
@ -258,13 +259,13 @@ func (c *certChecker) getCerts(ctx context.Context) error {
func (c *certChecker) processCerts(ctx context.Context, wg *sync.WaitGroup, badResultsOnly bool) {
for cert := range c.certs {
dnsNames, problems := c.checkCert(ctx, cert)
sans, problems := c.checkCert(ctx, cert)
valid := len(problems) == 0
c.rMu.Lock()
if !badResultsOnly || (badResultsOnly && !valid) {
c.issuedReport.Entries[cert.Serial] = reportEntry{
Valid: valid,
DNSNames: dnsNames,
SANs: sans,
Problems: problems,
}
}
@ -333,21 +334,29 @@ func (c *certChecker) checkValidations(ctx context.Context, cert *corepb.Certifi
return nil
}
// checkCert returns a list of DNS names in the certificate and a list of problems with the certificate.
// checkCert returns a list of Subject Alternative Names in the certificate and a list of problems with the certificate.
func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) ([]string, []string) {
var dnsNames []string
var problems []string
// Check that the digests match.
if cert.Digest != core.Fingerprint256(cert.Der) {
problems = append(problems, "Stored digest doesn't match certificate digest")
}
// Parse the certificate.
parsedCert, err := zX509.ParseCertificate(cert.Der)
if err != nil {
problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err))
} else {
dnsNames = parsedCert.DNSNames
// This is a fatal error, we can't do any further processing.
return nil, problems
}
// Now that it's parsed, we can extract the SANs.
sans := slices.Clone(parsedCert.DNSNames)
for _, ip := range parsedCert.IPAddresses {
sans = append(sans, ip.String())
}
// Run zlint checks.
results := zlint.LintCertificateEx(parsedCert, c.lints)
for name, res := range results.Results {
@ -360,6 +369,7 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
}
problems = append(problems, prob)
}
// Check if stored serial is correct.
storedSerial, err := core.StringToSerial(cert.Serial)
if err != nil {
@ -367,18 +377,22 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
} else if parsedCert.SerialNumber.Cmp(storedSerial) != 0 {
problems = append(problems, "Stored serial doesn't match certificate serial")
}
// Check that we have the correct expiration time.
if !parsedCert.NotAfter.Equal(cert.Expires.AsTime()) {
problems = append(problems, "Stored expiration doesn't match certificate NotAfter")
}
// Check if basic constraints are set.
if !parsedCert.BasicConstraintsValid {
problems = append(problems, "Certificate doesn't have basic constraints set")
}
// Check that the cert isn't able to sign other certificates.
if parsedCert.IsCA {
problems = append(problems, "Certificate can sign other certificates")
}
// Check that the cert has a valid validity period. The validity
// period is computed inclusive of the whole final second indicated by
// notAfter.
@ -387,10 +401,17 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
if !ok {
problems = append(problems, "Certificate has unacceptable validity period")
}
// Check that the stored issuance time isn't too far back/forward dated.
if parsedCert.NotBefore.Before(cert.Issued.AsTime().Add(-6*time.Hour)) || parsedCert.NotBefore.After(cert.Issued.AsTime().Add(6*time.Hour)) {
problems = append(problems, "Stored issuance date is outside of 6 hour window of certificate NotBefore")
}
// Check that the cert doesn't contain any SANs of unexpected types.
if len(parsedCert.EmailAddresses) != 0 || len(parsedCert.URIs) != 0 {
problems = append(problems, "Certificate contains SAN of unacceptable type (email or URI)")
}
if parsedCert.Subject.CommonName != "" {
// Check if the CommonName is <= 64 characters.
if len(parsedCert.Subject.CommonName) > 64 {
@ -401,21 +422,21 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
}
// Check that the CommonName is included in the SANs.
if !slices.Contains(parsedCert.DNSNames, parsedCert.Subject.CommonName) {
if !slices.Contains(sans, parsedCert.Subject.CommonName) {
problems = append(problems, fmt.Sprintf("Certificate Common Name does not appear in Subject Alternative Names: %q !< %v",
parsedCert.Subject.CommonName, parsedCert.DNSNames))
}
}
// Check that the PA is still willing to issue for each name in DNSNames.
// We do not check the CommonName here, as (if it exists) we already checked
// that it is identical to one of the DNSNames in the SAN.
//
// TODO(#7311): We'll need to iterate over IP address identifiers too.
// Check that the PA is still willing to issue for each DNS name and IP
// address in the SANs. We do not check the CommonName here, as (if it exists)
// we already checked that it is identical to one of the DNSNames in the SAN.
for _, name := range parsedCert.DNSNames {
err = c.pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewDNS(name)})
if err != nil {
problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err))
} else {
continue
}
// For defense-in-depth, even if the PA was willing to issue for a name
// we double check it against a list of forbidden domains. This way even
// if the hostnamePolicyFile malfunctions we will flag the forbidden
@ -426,7 +447,19 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
"forbiddenDomains entry %q", name, pattern))
}
}
for _, name := range parsedCert.IPAddresses {
ip, ok := netip.AddrFromSlice(name)
if !ok {
problems = append(problems, fmt.Sprintf("SANs contain malformed IP %q", name))
continue
}
err = c.pa.WillingToIssue(identifier.ACMEIdentifiers{identifier.NewIP(ip)})
if err != nil {
problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err))
continue
}
}
// Check the cert has the correct key usage extensions
serverAndClient := slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth, zX509.ExtKeyUsageClientAuth})
serverOnly := slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth})
@ -454,11 +487,12 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
p, err := x509.ParseCertificate(cert.Der)
if err != nil {
problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err))
}
} else {
err = c.kp.GoodKey(ctx, p.PublicKey)
if err != nil {
problems = append(problems, fmt.Sprintf("Key Policy isn't willing to issue for public key: %s", err))
}
}
precertDER, err := c.getPrecert(ctx, cert.Serial)
if err != nil {
@ -469,8 +503,7 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
} else {
err = precert.Correspond(precertDER, cert.Der)
if err != nil {
problems = append(problems,
fmt.Sprintf("Certificate does not correspond to precert for %s: %s", cert.Serial, err))
problems = append(problems, fmt.Sprintf("Certificate does not correspond to precert for %s: %s", cert.Serial, err))
}
}
@ -489,8 +522,8 @@ func (c *certChecker) checkCert(ctx context.Context, cert *corepb.Certificate) (
}
}
}
}
return dnsNames, problems
return sans, problems
}
type Config struct {

View File

@ -143,7 +143,7 @@ func TestCheckWildcardCert(t *testing.T) {
}
}
func TestCheckCertReturnsDNSNames(t *testing.T) {
func TestCheckCertReturnsSANs(t *testing.T) {
saDbMap, err := sa.DBMapForTest(vars.DBConnSA)
test.AssertNotError(t, err, "Couldn't connect to database")
saCleanup := test.ResetBoulderTestDatabase(t)
@ -171,7 +171,7 @@ func TestCheckCertReturnsDNSNames(t *testing.T) {
}
names, problems := checker.checkCert(context.Background(), cert)
if !slices.Equal(names, []string{"quite_invalid.com", "al--so--wr--ong.com"}) {
if !slices.Equal(names, []string{"quite_invalid.com", "al--so--wr--ong.com", "127.0.0.1"}) {
t.Errorf("didn't get expected DNS names. other problems: %s", strings.Join(problems, "\n"))
}
}

View File

@ -1,5 +1,5 @@
-----BEGIN CERTIFICATE-----
MIIDUzCCAjugAwIBAgIILgLqdMwyzT4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
MIIDWTCCAkGgAwIBAgIILgLqdMwyzT4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgOTMzZTM5MB4XDTIxMTExMTIwMjMzMloXDTIzMTIx
MTIwMjMzMlowHDEaMBgGA1UEAwwRcXVpdGVfaW52YWxpZC5jb20wggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDi4jBbqMyvhMonDngNsvie9SHPB16mdpiy
@ -7,14 +7,14 @@ Y/agreU84xUz/roKK07TpVmeqvwWvDkvHTFov7ytKdnCY+z/NXKJ3hNqflWCwU7h
Uk9TmpBp0vg+5NvalYul/+bq/B4qDhEvTBzAX3k/UYzd0GQdMyAbwXtG41f5cSK6
cWTQYfJL3gGR5/KLoTz3/VemLgEgAP/CvgcUJPbQceQViiZ4opi9hFIfUqxX2NsD
49klw8cDFu/BG2LEC+XtbdT8XevD0aGIOuYVr+Pa2mxb2QCDXu4tXOsDXH9Y/Cmk
8103QbdB8Y+usOiHG/IXxK2q4J7QNPal4ER4/PGA06V0gwrjNH8BAgMBAAGjgZQw
gZEwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
8103QbdB8Y+usOiHG/IXxK2q4J7QNPal4ER4/PGA06V0gwrjNH8BAgMBAAGjgZow
gZcwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFNIcaCjv32YRafE065dZO57ONWuk
MDEGA1UdEQQqMCiCEXF1aXRlX2ludmFsaWQuY29tghNhbC0tc28tLXdyLS1vbmcu
Y29tMA0GCSqGSIb3DQEBCwUAA4IBAQAjSv0o5G4VuLnnwHON4P53bLvGnYqaqYju
TEafi3hSgHAfBuhOQUVgwujoYpPp1w1fm5spfcbSwNNRte79HgV97kAuZ4R4RHk1
5Xux1ITLalaHR/ilu002N0eJ7dFYawBgV2xMudULzohwmW2RjPJ5811iWwtiVf1b
A3V5SZJWSJll1BhANBs7R0pBbyTSNHR470N8TGG0jfXqgTKd0xZaH91HrwEMo+96
llbfp90Y5OfHIfym/N1sH2hVgd+ZAkhiVEiNBWZlbSyOgbZ1cCBvBXg6TuwpQMZK
9RWjlpni8yuzLGduPl8qHG1dqsUvbVqcG+WhHLbaZMNhiMfiWInL
MDcGA1UdEQQwMC6CEXF1aXRlX2ludmFsaWQuY29tghNhbC0tc28tLXdyLS1vbmcu
Y29thwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQAjSv0o5G4VuLnnwHON4P53bLvG
nYqaqYjuTEafi3hSgHAfBuhOQUVgwujoYpPp1w1fm5spfcbSwNNRte79HgV97kAu
Z4R4RHk15Xux1ITLalaHR/ilu002N0eJ7dFYawBgV2xMudULzohwmW2RjPJ5811i
WwtiVf1bA3V5SZJWSJll1BhANBs7R0pBbyTSNHR470N8TGG0jfXqgTKd0xZaH91H
rwEMo+96llbfp90Y5OfHIfym/N1sH2hVgd+ZAkhiVEiNBWZlbSyOgbZ1cCBvBXg6
TuwpQMZK9RWjlpni8yuzLGduPl8qHG1dqsUvbVqcG+WhHLbaZMNhiMfiWInL
-----END CERTIFICATE-----