427 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			427 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
package main
 | 
						|
 | 
						|
import (
 | 
						|
	"crypto/x509"
 | 
						|
	"encoding/json"
 | 
						|
	"flag"
 | 
						|
	"fmt"
 | 
						|
	"log/syslog"
 | 
						|
	"os"
 | 
						|
	"reflect"
 | 
						|
	"regexp"
 | 
						|
	"runtime"
 | 
						|
	"sync"
 | 
						|
	"sync/atomic"
 | 
						|
	"time"
 | 
						|
 | 
						|
	"github.com/jmhodges/clock"
 | 
						|
	"github.com/prometheus/client_golang/prometheus"
 | 
						|
 | 
						|
	"github.com/letsencrypt/boulder/cmd"
 | 
						|
	"github.com/letsencrypt/boulder/core"
 | 
						|
	"github.com/letsencrypt/boulder/features"
 | 
						|
	blog "github.com/letsencrypt/boulder/log"
 | 
						|
	"github.com/letsencrypt/boulder/metrics"
 | 
						|
	"github.com/letsencrypt/boulder/policy"
 | 
						|
	"github.com/letsencrypt/boulder/sa"
 | 
						|
 | 
						|
	lintasn1 "github.com/globalsign/certlint/asn1"
 | 
						|
	"github.com/globalsign/certlint/certdata"
 | 
						|
	"github.com/globalsign/certlint/checks"
 | 
						|
	_ "github.com/globalsign/certlint/checks/certificate/all"
 | 
						|
	_ "github.com/globalsign/certlint/checks/extensions/all"
 | 
						|
)
 | 
						|
 | 
						|
const (
 | 
						|
	good = "valid"
 | 
						|
	bad  = "invalid"
 | 
						|
 | 
						|
	certlintCNError = "commonName field is deprecated"
 | 
						|
 | 
						|
	filenameLayout = "20060102"
 | 
						|
 | 
						|
	expectedValidityPeriod = time.Hour * 24 * 90
 | 
						|
)
 | 
						|
 | 
						|
// certlintPSLErrPattern is a regex for matching Certlint error strings
 | 
						|
// complaining about a CN or SAN matching a public suffix list entry.
 | 
						|
var certlintPSLErrPattern = regexp.MustCompile(
 | 
						|
	`^Certificate (?:CommonName|subjectAltName) "[a-z0-9*][a-z0-9-.]+" ` +
 | 
						|
		`equals "[a-z0-9][a-z0-9-.]+" from the public suffix list$`)
 | 
						|
 | 
						|
// For defense-in-depth in addition to using the PA & its hostnamePolicy to
 | 
						|
// check domain names we also perform a check against the regex's from the
 | 
						|
// forbiddenDomains array
 | 
						|
var forbiddenDomainPatterns = []*regexp.Regexp{
 | 
						|
	regexp.MustCompile(`^\s*$`),
 | 
						|
	regexp.MustCompile(`\.local$`),
 | 
						|
	regexp.MustCompile(`^localhost$`),
 | 
						|
	regexp.MustCompile(`\.localhost$`),
 | 
						|
}
 | 
						|
 | 
						|
func isForbiddenDomain(name string) (bool, string) {
 | 
						|
	for _, r := range forbiddenDomainPatterns {
 | 
						|
		if matches := r.FindAllStringSubmatch(name, -1); len(matches) > 0 {
 | 
						|
			return true, r.String()
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return false, ""
 | 
						|
}
 | 
						|
 | 
						|
var batchSize = 1000
 | 
						|
 | 
						|
type report struct {
 | 
						|
	begin     time.Time
 | 
						|
	end       time.Time
 | 
						|
	GoodCerts int64                  `json:"good-certs"`
 | 
						|
	BadCerts  int64                  `json:"bad-certs"`
 | 
						|
	Entries   map[string]reportEntry `json:"entries"`
 | 
						|
}
 | 
						|
 | 
						|
func (r *report) dump() error {
 | 
						|
	content, err := json.MarshalIndent(r, "", "  ")
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
	fmt.Fprintln(os.Stdout, string(content))
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
type reportEntry struct {
 | 
						|
	Valid    bool     `json:"valid"`
 | 
						|
	Problems []string `json:"problems,omitempty"`
 | 
						|
}
 | 
						|
 | 
						|
/*
 | 
						|
 * certDB is an interface collecting the gorp.DbMap functions that the
 | 
						|
 * various parts of cert-checker rely on. Using this adapter shim allows tests to
 | 
						|
 * swap out the dbMap implementation.
 | 
						|
 */
 | 
						|
type certDB interface {
 | 
						|
	Select(i interface{}, query string, args ...interface{}) ([]interface{}, error)
 | 
						|
	SelectOne(holder interface{}, query string, args ...interface{}) error
 | 
						|
}
 | 
						|
 | 
						|
type certChecker struct {
 | 
						|
	pa           core.PolicyAuthority
 | 
						|
	dbMap        certDB
 | 
						|
	certs        chan core.Certificate
 | 
						|
	clock        clock.Clock
 | 
						|
	rMu          *sync.Mutex
 | 
						|
	issuedReport report
 | 
						|
	checkPeriod  time.Duration
 | 
						|
	stats        metrics.Scope
 | 
						|
}
 | 
						|
 | 
						|
func newChecker(saDbMap certDB, clk clock.Clock, pa core.PolicyAuthority, period time.Duration) certChecker {
 | 
						|
	c := certChecker{
 | 
						|
		pa:          pa,
 | 
						|
		dbMap:       saDbMap,
 | 
						|
		certs:       make(chan core.Certificate, batchSize),
 | 
						|
		rMu:         new(sync.Mutex),
 | 
						|
		clock:       clk,
 | 
						|
		checkPeriod: period,
 | 
						|
	}
 | 
						|
	c.issuedReport.Entries = make(map[string]reportEntry)
 | 
						|
 | 
						|
	return c
 | 
						|
}
 | 
						|
 | 
						|
func (c *certChecker) getCerts(unexpiredOnly bool) error {
 | 
						|
	c.issuedReport.end = c.clock.Now()
 | 
						|
	c.issuedReport.begin = c.issuedReport.end.Add(-c.checkPeriod)
 | 
						|
 | 
						|
	args := map[string]interface{}{"issued": c.issuedReport.begin, "now": 0}
 | 
						|
	if unexpiredOnly {
 | 
						|
		now := c.clock.Now()
 | 
						|
		args["now"] = now
 | 
						|
	}
 | 
						|
	var count int
 | 
						|
	err := c.dbMap.SelectOne(
 | 
						|
		&count,
 | 
						|
		"SELECT count(*) FROM certificates WHERE issued >= :issued AND expires >= :now",
 | 
						|
		args,
 | 
						|
	)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// Retrieve certs in batches of 1000 (the size of the certificate channel)
 | 
						|
	// so that we don't eat unnecessary amounts of memory and avoid the 16MB MySQL
 | 
						|
	// packet limit.
 | 
						|
	args["limit"] = batchSize
 | 
						|
	args["lastSerial"] = ""
 | 
						|
	for offset := 0; offset < count; {
 | 
						|
		certs, err := sa.SelectCertificates(
 | 
						|
			c.dbMap,
 | 
						|
			"WHERE issued >= :issued AND expires >= :now AND serial > :lastSerial LIMIT :limit",
 | 
						|
			args,
 | 
						|
		)
 | 
						|
		if err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
		for _, cert := range certs {
 | 
						|
			c.certs <- cert
 | 
						|
		}
 | 
						|
		if len(certs) == 0 {
 | 
						|
			break
 | 
						|
		}
 | 
						|
		args["lastSerial"] = certs[len(certs)-1].Serial
 | 
						|
		offset += len(certs)
 | 
						|
	}
 | 
						|
 | 
						|
	// Close channel so range operations won't block once the channel empties out
 | 
						|
	close(c.certs)
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (c *certChecker) processCerts(wg *sync.WaitGroup, badResultsOnly bool) {
 | 
						|
	for cert := range c.certs {
 | 
						|
		problems := c.checkCert(cert)
 | 
						|
		valid := len(problems) == 0
 | 
						|
		c.rMu.Lock()
 | 
						|
		if !badResultsOnly || (badResultsOnly && !valid) {
 | 
						|
			c.issuedReport.Entries[cert.Serial] = reportEntry{
 | 
						|
				Valid:    valid,
 | 
						|
				Problems: problems,
 | 
						|
			}
 | 
						|
		}
 | 
						|
		c.rMu.Unlock()
 | 
						|
		if !valid {
 | 
						|
			atomic.AddInt64(&c.issuedReport.BadCerts, 1)
 | 
						|
		} else {
 | 
						|
			atomic.AddInt64(&c.issuedReport.GoodCerts, 1)
 | 
						|
		}
 | 
						|
	}
 | 
						|
	wg.Done()
 | 
						|
}
 | 
						|
 | 
						|
func (c *certChecker) checkCert(cert core.Certificate) (problems []string) {
 | 
						|
	// Check digests match
 | 
						|
	if cert.Digest != core.Fingerprint256(cert.DER) {
 | 
						|
		problems = append(problems, "Stored digest doesn't match certificate digest")
 | 
						|
	}
 | 
						|
 | 
						|
	// Run linter
 | 
						|
	linter := new(lintasn1.Linter)
 | 
						|
	errs := linter.CheckStruct(cert.DER)
 | 
						|
	if errs != nil {
 | 
						|
		for _, err := range errs.List() {
 | 
						|
			problems = append(problems, err.Error())
 | 
						|
		}
 | 
						|
	}
 | 
						|
	d, err := certdata.Load(cert.DER)
 | 
						|
	if err != nil {
 | 
						|
		problems = append(problems, err.Error())
 | 
						|
	}
 | 
						|
	errs = checks.Certificate.Check(d)
 | 
						|
	if errs != nil {
 | 
						|
		for _, err := range errs.List() {
 | 
						|
			// commonName has been deprecated for years, but common practice is still
 | 
						|
			// to include it for compatibility reasons. For instance, Chrome on macOS
 | 
						|
			// until very recently would error on an empty Subject (which is what we
 | 
						|
			// would have if we omitted CommonName). There have been proposals at
 | 
						|
			// CA/Browser Forum for an alternate contentless field whose purpose would
 | 
						|
			// just be to make Subject non-empty, but so far they have not been
 | 
						|
			// successful. If the check error is `certlintCNError`, ignore it.
 | 
						|
			if err.Error() == certlintCNError {
 | 
						|
				continue
 | 
						|
			}
 | 
						|
			// certlint incorrectly flags certificates that have a subj. CN or SAN
 | 
						|
			// exactly equal to a *private* entry on the public suffix list. Since
 | 
						|
			// this is allowed and LE issues certificates for such names we ignore
 | 
						|
			// errors of this form until the upstream bug can be addressed. See
 | 
						|
			// https://github.com/globalsign/certlint/issues/17
 | 
						|
			if certlintPSLErrPattern.MatchString(err.Error()) {
 | 
						|
				continue
 | 
						|
			}
 | 
						|
			problems = append(problems, err.Error())
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// Parse certificate
 | 
						|
	parsedCert, err := x509.ParseCertificate(cert.DER)
 | 
						|
	if err != nil {
 | 
						|
		problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err))
 | 
						|
	} else {
 | 
						|
		// Check stored serial is correct
 | 
						|
		storedSerial, err := core.StringToSerial(cert.Serial)
 | 
						|
		if err != nil {
 | 
						|
			problems = append(problems, "Stored serial is invalid")
 | 
						|
		} else if parsedCert.SerialNumber.Cmp(storedSerial) != 0 {
 | 
						|
			problems = append(problems, "Stored serial doesn't match certificate serial")
 | 
						|
		}
 | 
						|
		// Check we have the right expiration time
 | 
						|
		if !parsedCert.NotAfter.Equal(cert.Expires) {
 | 
						|
			problems = append(problems, "Stored expiration doesn't match certificate NotAfter")
 | 
						|
		}
 | 
						|
		// Check basic constraints are set
 | 
						|
		if !parsedCert.BasicConstraintsValid {
 | 
						|
			problems = append(problems, "Certificate doesn't have basic constraints set")
 | 
						|
		}
 | 
						|
		// Check the cert isn't able to sign other certificates
 | 
						|
		if parsedCert.IsCA {
 | 
						|
			problems = append(problems, "Certificate can sign other certificates")
 | 
						|
		}
 | 
						|
		// Check the cert has the correct validity period
 | 
						|
		validityPeriod := parsedCert.NotAfter.Sub(parsedCert.NotBefore)
 | 
						|
		if validityPeriod > expectedValidityPeriod {
 | 
						|
			problems = append(problems, fmt.Sprintf("Certificate has a validity period longer than %s", expectedValidityPeriod))
 | 
						|
		} else if validityPeriod < expectedValidityPeriod {
 | 
						|
			problems = append(problems, fmt.Sprintf("Certificate has a validity period shorter than %s", expectedValidityPeriod))
 | 
						|
		}
 | 
						|
		// Check the stored issuance time isn't too far back/forward dated
 | 
						|
		if parsedCert.NotBefore.Before(cert.Issued.Add(-6*time.Hour)) || parsedCert.NotBefore.After(cert.Issued.Add(6*time.Hour)) {
 | 
						|
			problems = append(problems, "Stored issuance date is outside of 6 hour window of certificate NotBefore")
 | 
						|
		}
 | 
						|
		// Check CommonName is <= 64 characters
 | 
						|
		if len(parsedCert.Subject.CommonName) > 64 {
 | 
						|
			problems = append(
 | 
						|
				problems,
 | 
						|
				fmt.Sprintf("Certificate has common name >64 characters long (%d)", len(parsedCert.Subject.CommonName)),
 | 
						|
			)
 | 
						|
		}
 | 
						|
		// Check that the PA is still willing to issue for each name in DNSNames + CommonName
 | 
						|
		for _, name := range append(parsedCert.DNSNames, parsedCert.Subject.CommonName) {
 | 
						|
			id := core.AcmeIdentifier{Type: core.IdentifierDNS, Value: name}
 | 
						|
			// TODO(https://github.com/letsencrypt/boulder/issues/3371): Distinguish
 | 
						|
			// between certificates issued by v1 and v2 API.
 | 
						|
			checkFunc := c.pa.WillingToIssue
 | 
						|
			if features.Enabled(features.WildcardDomains) {
 | 
						|
				checkFunc = c.pa.WillingToIssueWildcard
 | 
						|
			}
 | 
						|
			if err = checkFunc(id); err != nil {
 | 
						|
				problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err))
 | 
						|
			} else {
 | 
						|
				// 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
 | 
						|
				// domain matches
 | 
						|
				if forbidden, pattern := isForbiddenDomain(name); forbidden {
 | 
						|
					problems = append(problems, fmt.Sprintf(
 | 
						|
						"Policy Authority was willing to issue but domain '%s' matches "+
 | 
						|
							"forbiddenDomains entry %q", name, pattern))
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
		// Check the cert has the correct key usage extensions
 | 
						|
		if !reflect.DeepEqual(parsedCert.ExtKeyUsage, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}) {
 | 
						|
			problems = append(problems, "Certificate has incorrect key usage extensions")
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return problems
 | 
						|
}
 | 
						|
 | 
						|
type config struct {
 | 
						|
	CertChecker struct {
 | 
						|
		cmd.DBConfig
 | 
						|
		cmd.HostnamePolicyConfig
 | 
						|
 | 
						|
		Workers             int
 | 
						|
		ReportDirectoryPath string
 | 
						|
		UnexpiredOnly       bool
 | 
						|
		BadResultsOnly      bool
 | 
						|
		CheckPeriod         cmd.ConfigDuration
 | 
						|
 | 
						|
		Features map[string]bool
 | 
						|
	}
 | 
						|
 | 
						|
	PA cmd.PAConfig
 | 
						|
 | 
						|
	Statsd cmd.StatsdConfig
 | 
						|
 | 
						|
	Syslog cmd.SyslogConfig
 | 
						|
}
 | 
						|
 | 
						|
func main() {
 | 
						|
	configFile := flag.String("config", "", "File path to the configuration file for this service")
 | 
						|
	workers := flag.Int("workers", runtime.NumCPU(), "The number of concurrent workers used to process certificates")
 | 
						|
	badResultsOnly := flag.Bool("bad-results-only", false, "Only collect and display bad results")
 | 
						|
	connect := flag.String("db-connect", "", "SQL URI if not provided in the configuration file")
 | 
						|
	cp := flag.Duration("check-period", time.Hour*2160, "How far back to check")
 | 
						|
	unexpiredOnly := flag.Bool("unexpired-only", false, "Only check currently unexpired certificates")
 | 
						|
 | 
						|
	flag.Parse()
 | 
						|
	if *configFile == "" {
 | 
						|
		flag.Usage()
 | 
						|
		os.Exit(1)
 | 
						|
	}
 | 
						|
 | 
						|
	var config config
 | 
						|
	err := cmd.ReadConfigFile(*configFile, &config)
 | 
						|
	cmd.FailOnError(err, "Reading JSON config file into config structure")
 | 
						|
 | 
						|
	err = features.Set(config.CertChecker.Features)
 | 
						|
	cmd.FailOnError(err, "Failed to set feature flags")
 | 
						|
 | 
						|
	syslogger, err := syslog.Dial("", "", syslog.LOG_INFO|syslog.LOG_LOCAL0, "")
 | 
						|
	cmd.FailOnError(err, "Failed to dial syslog")
 | 
						|
	logger, err := blog.New(syslogger, 0, 0)
 | 
						|
	cmd.FailOnError(err, "Failed to construct logger")
 | 
						|
	err = blog.Set(logger)
 | 
						|
	cmd.FailOnError(err, "Failed to set audit logger")
 | 
						|
 | 
						|
	if *connect != "" {
 | 
						|
		config.CertChecker.DBConnect = *connect
 | 
						|
	}
 | 
						|
	if *workers != 0 {
 | 
						|
		config.CertChecker.Workers = *workers
 | 
						|
	}
 | 
						|
	config.CertChecker.UnexpiredOnly = *unexpiredOnly
 | 
						|
	config.CertChecker.BadResultsOnly = *badResultsOnly
 | 
						|
	config.CertChecker.CheckPeriod.Duration = *cp
 | 
						|
 | 
						|
	// Validate PA config and set defaults if needed
 | 
						|
	cmd.FailOnError(config.PA.CheckChallenges(), "Invalid PA configuration")
 | 
						|
 | 
						|
	saDbURL, err := config.CertChecker.DBConfig.URL()
 | 
						|
	cmd.FailOnError(err, "Couldn't load DB URL")
 | 
						|
	saDbMap, err := sa.NewDbMap(saDbURL, config.CertChecker.DBConfig.MaxDBConns)
 | 
						|
	cmd.FailOnError(err, "Could not connect to database")
 | 
						|
	scope := metrics.NewPromScope(prometheus.DefaultRegisterer)
 | 
						|
	go sa.ReportDbConnCount(saDbMap, scope)
 | 
						|
 | 
						|
	pa, err := policy.New(config.PA.Challenges)
 | 
						|
	cmd.FailOnError(err, "Failed to create PA")
 | 
						|
	err = pa.SetHostnamePolicyFile(config.CertChecker.HostnamePolicyFile)
 | 
						|
	cmd.FailOnError(err, "Failed to load HostnamePolicyFile")
 | 
						|
 | 
						|
	checker := newChecker(
 | 
						|
		saDbMap,
 | 
						|
		cmd.Clock(),
 | 
						|
		pa,
 | 
						|
		config.CertChecker.CheckPeriod.Duration,
 | 
						|
	)
 | 
						|
	fmt.Fprintf(os.Stderr, "# Getting certificates issued in the last %s\n", config.CertChecker.CheckPeriod)
 | 
						|
 | 
						|
	// Since we grab certificates in batches we don't want this to block, when it
 | 
						|
	// is finished it will close the certificate channel which allows the range
 | 
						|
	// loops in checker.processCerts to break
 | 
						|
	go func() {
 | 
						|
		err := checker.getCerts(config.CertChecker.UnexpiredOnly)
 | 
						|
		cmd.FailOnError(err, "Batch retrieval of certificates failed")
 | 
						|
	}()
 | 
						|
 | 
						|
	fmt.Fprintf(os.Stderr, "# Processing certificates using %d workers\n", config.CertChecker.Workers)
 | 
						|
	wg := new(sync.WaitGroup)
 | 
						|
	for i := 0; i < config.CertChecker.Workers; i++ {
 | 
						|
		wg.Add(1)
 | 
						|
		go func() {
 | 
						|
			s := checker.clock.Now()
 | 
						|
			checker.processCerts(wg, config.CertChecker.BadResultsOnly)
 | 
						|
			scope.TimingDuration("certChecker.processingLatency", time.Since(s))
 | 
						|
		}()
 | 
						|
	}
 | 
						|
	wg.Wait()
 | 
						|
	fmt.Fprintf(
 | 
						|
		os.Stderr,
 | 
						|
		"# Finished processing certificates, sample: %d, good: %d, bad: %d\n",
 | 
						|
		len(checker.issuedReport.Entries),
 | 
						|
		checker.issuedReport.GoodCerts,
 | 
						|
		checker.issuedReport.BadCerts,
 | 
						|
	)
 | 
						|
	err = checker.issuedReport.dump()
 | 
						|
	cmd.FailOnError(err, "Failed to dump results: %s\n")
 | 
						|
 | 
						|
}
 |