371 lines
11 KiB
Go
371 lines
11 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"
|
|
)
|
|
|
|
const (
|
|
good = "valid"
|
|
bad = "invalid"
|
|
|
|
filenameLayout = "20060102"
|
|
|
|
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(`\.mil$`),
|
|
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")
|
|
}
|
|
|
|
// 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}
|
|
if err = c.pa.WillingToIssue(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,
|
|
clock.Default(),
|
|
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")
|
|
|
|
}
|