boulder/cmd/cert-checker/main.go

439 lines
14 KiB
Go

package main
import (
"bytes"
"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/zmap/zcrypto/x509"
"github.com/zmap/zlint/v2"
"github.com/zmap/zlint/v2/lint"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/identifier"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/policy"
"github.com/letsencrypt/boulder/sa"
)
const (
expectedValidityPeriod = time.Hour * 24 * 90
)
// 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
}
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
}
var initialID int
err = c.dbMap.SelectOne(
&initialID,
"SELECT id FROM certificates WHERE issued >= :issued AND expires >= :now LIMIT 1",
args,
)
if err != nil {
return err
}
if initialID > 0 {
// decrement the initial ID so that we select below as we aren't using >=
initialID -= 1
}
// 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["id"] = initialID
for offset := 0; offset < count; {
certs, err := sa.SelectCertificates(
c.dbMap,
"WHERE id > :id AND expires >= :now ORDER BY id LIMIT :limit",
args,
)
if err != nil {
return err
}
for _, cert := range certs {
c.certs <- cert.Certificate
}
if len(certs) == 0 {
break
}
args["id"] = certs[len(certs)-1].ID
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, ignoredLints map[string]bool) {
for cert := range c.certs {
problems := c.checkCert(cert, ignoredLints)
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()
}
// Extensions that we allow in certificates
var allowedExtensions = map[string]bool{
"1.3.6.1.5.5.7.1.1": true, // Authority info access
"2.5.29.35": true, // Authority key identifier
"2.5.29.19": true, // Basic constraints
"2.5.29.32": true, // Certificate policies
"2.5.29.31": true, // CRL distribution points
"2.5.29.37": true, // Extended key usage
"2.5.29.15": true, // Key usage
"2.5.29.17": true, // Subject alternative name
"2.5.29.14": true, // Subject key identifier
"1.3.6.1.4.1.11129.2.4.2": true, // SCT list
"1.3.6.1.5.5.7.1.24": true, // TLS feature
}
// For extensions that have a fixed value we check that it contains that value
var expectedExtensionContent = map[string][]byte{
"1.3.6.1.5.5.7.1.24": {0x30, 0x03, 0x02, 0x01, 0x05}, // Must staple feature
}
func (c *certChecker) checkCert(cert core.Certificate, ignoredLints map[string]bool) (problems []string) {
// Check digests match
if cert.Digest != core.Fingerprint256(cert.DER) {
problems = append(problems, "Stored digest doesn't match certificate digest")
}
// Parse certificate
parsedCert, err := x509.ParseCertificate(cert.DER)
if err != nil {
problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err))
} else {
// Run zlint checks
results := zlint.LintCertificate(parsedCert)
for name, res := range results.Results {
if ignoredLints[name] || res.Status <= lint.Pass {
continue
}
prob := fmt.Sprintf("zlint %s: %s", res.Status, name)
if res.Details != "" {
prob = fmt.Sprintf("%s %s", prob, res.Details)
}
problems = append(problems, prob)
}
// 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 := identifier.ACMEIdentifier{Type: identifier.DNS, Value: name}
// TODO(https://github.com/letsencrypt/boulder/issues/3371): Distinguish
// between certificates issued by v1 and v2 API.
if err = c.pa.WillingToIssueWildcards([]identifier.ACMEIdentifier{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")
}
for _, ext := range parsedCert.Extensions {
if _, ok := allowedExtensions[ext.Id.String()]; !ok {
problems = append(problems, fmt.Sprintf("Certificate contains an unexpected extension: %s", ext.Id))
}
if expectedContent, ok := expectedExtensionContent[ext.Id.String()]; ok {
if !bytes.Equal(ext.Value, expectedContent) {
problems = append(problems, fmt.Sprintf("Certificate extension %s contains unexpected content: has %x, expected %x", ext.Id, ext.Value, expectedContent))
}
}
}
}
return problems
}
type config struct {
CertChecker struct {
cmd.DBConfig
cmd.HostnamePolicyConfig
Workers int
ReportDirectoryPath string
UnexpiredOnly bool
BadResultsOnly bool
CheckPeriod cmd.ConfigDuration
// IgnoredLints is a list of zlint names. Any lint results from a lint in
// the IgnoredLists list are ignored regardless of LintStatus level.
IgnoredLints []string
Features map[string]bool
}
PA cmd.PAConfig
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")
sa.InitDBMetrics(saDbMap, prometheus.DefaultRegisterer)
checkerLatency := prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "cert_checker_latency",
Help: "Histogram of latencies a cert-checker worker takes to complete a batch",
})
prometheus.DefaultRegisterer.MustRegister(checkerLatency)
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)
ignoredLintsMap := make(map[string]bool)
for _, name := range config.CertChecker.IgnoredLints {
ignoredLintsMap[name] = true
}
// 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, ignoredLintsMap)
checkerLatency.Observe(checker.clock.Since(s).Seconds())
}()
}
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")
}