1 package notmain
2
3 import (
4 "bytes"
5 "context"
6 "crypto/x509"
7 "database/sql"
8 "encoding/json"
9 "flag"
10 "fmt"
11 "log/syslog"
12 "os"
13 "regexp"
14 "slices"
15 "sync"
16 "sync/atomic"
17 "time"
18
19 "github.com/jmhodges/clock"
20 "github.com/prometheus/client_golang/prometheus"
21 zX509 "github.com/zmap/zcrypto/x509"
22 "github.com/zmap/zlint/v3"
23 "github.com/zmap/zlint/v3/lint"
24
25 "github.com/letsencrypt/boulder/cmd"
26 "github.com/letsencrypt/boulder/config"
27 "github.com/letsencrypt/boulder/core"
28 corepb "github.com/letsencrypt/boulder/core/proto"
29 "github.com/letsencrypt/boulder/ctpolicy/loglist"
30 "github.com/letsencrypt/boulder/features"
31 "github.com/letsencrypt/boulder/goodkey"
32 "github.com/letsencrypt/boulder/goodkey/sagoodkey"
33 "github.com/letsencrypt/boulder/identifier"
34 _ "github.com/letsencrypt/boulder/linter"
35 blog "github.com/letsencrypt/boulder/log"
36 "github.com/letsencrypt/boulder/policy"
37 "github.com/letsencrypt/boulder/precert"
38 "github.com/letsencrypt/boulder/sa"
39 )
40
41
42
43
44 var forbiddenDomainPatterns = []*regexp.Regexp{
45 regexp.MustCompile(`^\s*$`),
46 regexp.MustCompile(`\.local$`),
47 regexp.MustCompile(`^localhost$`),
48 regexp.MustCompile(`\.localhost$`),
49 }
50
51 func isForbiddenDomain(name string) (bool, string) {
52 for _, r := range forbiddenDomainPatterns {
53 if matches := r.FindAllStringSubmatch(name, -1); len(matches) > 0 {
54 return true, r.String()
55 }
56 }
57 return false, ""
58 }
59
60 var batchSize = 1000
61
62 type report struct {
63 begin time.Time
64 end time.Time
65 GoodCerts int64 `json:"good-certs"`
66 BadCerts int64 `json:"bad-certs"`
67 DbErrs int64 `json:"db-errs"`
68 Entries map[string]reportEntry `json:"entries"`
69 }
70
71 func (r *report) dump() error {
72 content, err := json.MarshalIndent(r, "", " ")
73 if err != nil {
74 return err
75 }
76 fmt.Fprintln(os.Stdout, string(content))
77 return nil
78 }
79
80 type reportEntry struct {
81 Valid bool `json:"valid"`
82 DNSNames []string `json:"dnsNames"`
83 Problems []string `json:"problems,omitempty"`
84 }
85
86
87
88
89 type certDB interface {
90 Select(ctx context.Context, i interface{}, query string, args ...interface{}) ([]interface{}, error)
91 SelectOne(ctx context.Context, i interface{}, query string, args ...interface{}) error
92 SelectNullInt(ctx context.Context, query string, args ...interface{}) (sql.NullInt64, error)
93 }
94
95
96
97 type precertGetter func(context.Context, string) ([]byte, error)
98
99 type certChecker struct {
100 pa core.PolicyAuthority
101 kp goodkey.KeyPolicy
102 dbMap certDB
103 getPrecert precertGetter
104 certs chan core.Certificate
105 clock clock.Clock
106 rMu *sync.Mutex
107 issuedReport report
108 checkPeriod time.Duration
109 acceptableValidityDurations map[time.Duration]bool
110 logger blog.Logger
111 }
112
113 func newChecker(saDbMap certDB,
114 clk clock.Clock,
115 pa core.PolicyAuthority,
116 kp goodkey.KeyPolicy,
117 period time.Duration,
118 avd map[time.Duration]bool,
119 logger blog.Logger,
120 ) certChecker {
121 precertGetter := func(ctx context.Context, serial string) ([]byte, error) {
122 precertPb, err := sa.SelectPrecertificate(ctx, saDbMap, serial)
123 if err != nil {
124 return nil, err
125 }
126 return precertPb.DER, nil
127 }
128 return certChecker{
129 pa: pa,
130 kp: kp,
131 dbMap: saDbMap,
132 getPrecert: precertGetter,
133 certs: make(chan core.Certificate, batchSize),
134 rMu: new(sync.Mutex),
135 clock: clk,
136 issuedReport: report{Entries: make(map[string]reportEntry)},
137 checkPeriod: period,
138 acceptableValidityDurations: avd,
139 logger: logger,
140 }
141 }
142
143
144
145 func (c *certChecker) findStartingID(ctx context.Context, begin, end time.Time) (int64, error) {
146 var output sql.NullInt64
147 var err error
148 var retries int
149
150
151
152
153
154
155
156 queryBegin := begin
157 queryEnd := begin.Add(time.Hour)
158
159 for queryBegin.Compare(end) < 0 {
160 output, err = c.dbMap.SelectNullInt(
161 ctx,
162 `SELECT MIN(id) FROM certificates
163 WHERE issued >= :begin AND
164 issued < :end`,
165 map[string]interface{}{
166 "begin": queryBegin,
167 "end": queryEnd,
168 },
169 )
170 if err != nil {
171 c.logger.AuditErrf("finding starting certificate: %s", err)
172 retries++
173 time.Sleep(core.RetryBackoff(retries, time.Second, time.Minute, 2))
174 continue
175 }
176
177
178
179
180 if !output.Valid {
181
182 queryBegin = queryBegin.Add(time.Hour)
183 queryEnd = queryEnd.Add(time.Hour)
184 if queryEnd.Compare(end) > 0 {
185 queryEnd = end
186 }
187 continue
188 }
189
190 return output.Int64, nil
191 }
192
193
194 return 0, fmt.Errorf("no rows found for certificates issued between %s and %s", begin, end)
195 }
196
197 func (c *certChecker) getCerts(ctx context.Context) error {
198
199 c.issuedReport.end = c.clock.Now().Truncate(time.Second).Add(time.Second)
200
201 c.issuedReport.begin = c.issuedReport.end.Add(-c.checkPeriod).Truncate(time.Second)
202
203 initialID, err := c.findStartingID(ctx, c.issuedReport.begin, c.issuedReport.end)
204 if err != nil {
205 return err
206 }
207 if initialID > 0 {
208
209 initialID -= 1
210 }
211
212 batchStartID := initialID
213 var retries int
214 for {
215 certs, err := sa.SelectCertificates(
216 ctx,
217 c.dbMap,
218 `WHERE id > :id AND
219 issued >= :begin AND
220 issued < :end
221 ORDER BY id LIMIT :limit`,
222 map[string]interface{}{
223 "begin": c.issuedReport.begin,
224 "end": c.issuedReport.end,
225
226
227
228 "limit": batchSize,
229 "id": batchStartID,
230 },
231 )
232 if err != nil {
233 c.logger.AuditErrf("selecting certificates: %s", err)
234 retries++
235 time.Sleep(core.RetryBackoff(retries, time.Second, time.Minute, 2))
236 continue
237 }
238 retries = 0
239 for _, cert := range certs {
240 c.certs <- cert.Certificate
241 }
242 if len(certs) == 0 {
243 break
244 }
245 lastCert := certs[len(certs)-1]
246 batchStartID = lastCert.ID
247 if lastCert.Issued.After(c.issuedReport.end) {
248 break
249 }
250 }
251
252
253 close(c.certs)
254 return nil
255 }
256
257 func (c *certChecker) processCerts(ctx context.Context, wg *sync.WaitGroup, badResultsOnly bool, ignoredLints map[string]bool) {
258 for cert := range c.certs {
259 dnsNames, problems := c.checkCert(ctx, cert, ignoredLints)
260 valid := len(problems) == 0
261 c.rMu.Lock()
262 if !badResultsOnly || (badResultsOnly && !valid) {
263 c.issuedReport.Entries[cert.Serial] = reportEntry{
264 Valid: valid,
265 DNSNames: dnsNames,
266 Problems: problems,
267 }
268 }
269 c.rMu.Unlock()
270 if !valid {
271 atomic.AddInt64(&c.issuedReport.BadCerts, 1)
272 } else {
273 atomic.AddInt64(&c.issuedReport.GoodCerts, 1)
274 }
275 }
276 wg.Done()
277 }
278
279
280 var allowedExtensions = map[string]bool{
281 "1.3.6.1.5.5.7.1.1": true,
282 "2.5.29.35": true,
283 "2.5.29.19": true,
284 "2.5.29.32": true,
285 "2.5.29.31": true,
286 "2.5.29.37": true,
287 "2.5.29.15": true,
288 "2.5.29.17": true,
289 "2.5.29.14": true,
290 "1.3.6.1.4.1.11129.2.4.2": true,
291 "1.3.6.1.5.5.7.1.24": true,
292 }
293
294
295 var expectedExtensionContent = map[string][]byte{
296 "1.3.6.1.5.5.7.1.24": {0x30, 0x03, 0x02, 0x01, 0x05},
297 }
298
299
300
301
302
303 func (c *certChecker) checkValidations(ctx context.Context, cert core.Certificate, dnsNames []string) error {
304 authzs, err := sa.SelectAuthzsMatchingIssuance(ctx, c.dbMap, cert.RegistrationID, cert.Issued, dnsNames)
305 if err != nil {
306 return fmt.Errorf("error checking authzs for certificate %s: %w", cert.Serial, err)
307 }
308
309 if len(authzs) == 0 {
310 return fmt.Errorf("no relevant authzs found valid at %s", cert.Issued)
311 }
312
313
314
315 nameToAuthz := make(map[string]*corepb.Authorization)
316 for _, m := range authzs {
317 nameToAuthz[m.Identifier] = m
318 }
319
320 var errors []error
321 for _, name := range dnsNames {
322 _, ok := nameToAuthz[name]
323 if !ok {
324 errors = append(errors, fmt.Errorf("missing authz for %q", name))
325 continue
326 }
327 }
328 if len(errors) > 0 {
329 return fmt.Errorf("%s", errors)
330 }
331 return nil
332 }
333
334
335 func (c *certChecker) checkCert(ctx context.Context, cert core.Certificate, ignoredLints map[string]bool) ([]string, []string) {
336 var dnsNames []string
337 var problems []string
338
339
340 if cert.Digest != core.Fingerprint256(cert.DER) {
341 problems = append(problems, "Stored digest doesn't match certificate digest")
342 }
343
344 parsedCert, err := zX509.ParseCertificate(cert.DER)
345 if err != nil {
346 problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err))
347 } else {
348 dnsNames = parsedCert.DNSNames
349
350 results := zlint.LintCertificate(parsedCert)
351 for name, res := range results.Results {
352 if ignoredLints[name] || res.Status <= lint.Pass {
353 continue
354 }
355 prob := fmt.Sprintf("zlint %s: %s", res.Status, name)
356 if res.Details != "" {
357 prob = fmt.Sprintf("%s %s", prob, res.Details)
358 }
359 problems = append(problems, prob)
360 }
361
362 storedSerial, err := core.StringToSerial(cert.Serial)
363 if err != nil {
364 problems = append(problems, "Stored serial is invalid")
365 } else if parsedCert.SerialNumber.Cmp(storedSerial) != 0 {
366 problems = append(problems, "Stored serial doesn't match certificate serial")
367 }
368
369 if !parsedCert.NotAfter.Equal(cert.Expires) {
370 problems = append(problems, "Stored expiration doesn't match certificate NotAfter")
371 }
372
373 if !parsedCert.BasicConstraintsValid {
374 problems = append(problems, "Certificate doesn't have basic constraints set")
375 }
376
377 if parsedCert.IsCA {
378 problems = append(problems, "Certificate can sign other certificates")
379 }
380
381
382
383 validityDuration := parsedCert.NotAfter.Add(time.Second).Sub(parsedCert.NotBefore)
384 _, ok := c.acceptableValidityDurations[validityDuration]
385 if !ok {
386 problems = append(problems, "Certificate has unacceptable validity period")
387 }
388
389 if parsedCert.NotBefore.Before(cert.Issued.Add(-6*time.Hour)) || parsedCert.NotBefore.After(cert.Issued.Add(6*time.Hour)) {
390 problems = append(problems, "Stored issuance date is outside of 6 hour window of certificate NotBefore")
391 }
392
393 if len(parsedCert.Subject.CommonName) > 64 {
394 problems = append(
395 problems,
396 fmt.Sprintf("Certificate has common name >64 characters long (%d)", len(parsedCert.Subject.CommonName)),
397 )
398 }
399
400
401 for _, name := range append(parsedCert.DNSNames, parsedCert.Subject.CommonName) {
402 id := identifier.ACMEIdentifier{Type: identifier.DNS, Value: name}
403 err = c.pa.WillingToIssueWildcards([]identifier.ACMEIdentifier{id})
404 if err != nil {
405 problems = append(problems, fmt.Sprintf("Policy Authority isn't willing to issue for '%s': %s", name, err))
406 } else {
407
408
409
410
411 if forbidden, pattern := isForbiddenDomain(name); forbidden {
412 problems = append(problems, fmt.Sprintf(
413 "Policy Authority was willing to issue but domain '%s' matches "+
414 "forbiddenDomains entry %q", name, pattern))
415 }
416 }
417 }
418
419 if !slices.Equal(parsedCert.ExtKeyUsage, []zX509.ExtKeyUsage{zX509.ExtKeyUsageServerAuth, zX509.ExtKeyUsageClientAuth}) {
420 problems = append(problems, "Certificate has incorrect key usage extensions")
421 }
422
423 for _, ext := range parsedCert.Extensions {
424 _, ok := allowedExtensions[ext.Id.String()]
425 if !ok {
426 problems = append(problems, fmt.Sprintf("Certificate contains an unexpected extension: %s", ext.Id))
427 }
428 expectedContent, ok := expectedExtensionContent[ext.Id.String()]
429 if ok {
430 if !bytes.Equal(ext.Value, expectedContent) {
431 problems = append(problems, fmt.Sprintf("Certificate extension %s contains unexpected content: has %x, expected %x", ext.Id, ext.Value, expectedContent))
432 }
433 }
434 }
435
436
437
438
439
440 p, err := x509.ParseCertificate(cert.DER)
441 if err != nil {
442 problems = append(problems, fmt.Sprintf("Couldn't parse stored certificate: %s", err))
443 }
444 err = c.kp.GoodKey(ctx, p.PublicKey)
445 if err != nil {
446 problems = append(problems, fmt.Sprintf("Key Policy isn't willing to issue for public key: %s", err))
447 }
448
449 if features.Enabled(features.CertCheckerRequiresCorrespondence) {
450 precertDER, err := c.getPrecert(ctx, cert.Serial)
451 if err != nil {
452
453
454 c.logger.Errf("fetching linting precertificate for %s: %s", cert.Serial, err)
455 atomic.AddInt64(&c.issuedReport.DbErrs, 1)
456 } else {
457 err = precert.Correspond(precertDER, cert.DER)
458 if err != nil {
459 problems = append(problems,
460 fmt.Sprintf("Certificate does not correspond to precert for %s: %s", cert.Serial, err))
461 }
462 }
463 }
464
465 if features.Enabled(features.CertCheckerChecksValidations) {
466 err = c.checkValidations(ctx, cert, parsedCert.DNSNames)
467 if err != nil {
468 if features.Enabled(features.CertCheckerRequiresValidations) {
469 problems = append(problems, err.Error())
470 } else {
471 c.logger.Errf("Certificate %s %s: %s", cert.Serial, parsedCert.DNSNames, err)
472 }
473 }
474 }
475 }
476 return dnsNames, problems
477 }
478
479 type Config struct {
480 CertChecker struct {
481 DB cmd.DBConfig
482 cmd.HostnamePolicyConfig
483
484 Workers int `validate:"required,min=1"`
485
486 UnexpiredOnly bool
487 BadResultsOnly bool
488 CheckPeriod config.Duration
489
490
491
492 AcceptableValidityDurations []config.Duration
493
494
495
496
497 GoodKey goodkey.Config
498
499
500
501 IgnoredLints []string
502
503
504
505
506 CTLogListFile string
507
508 Features map[string]bool
509 }
510 PA cmd.PAConfig
511 Syslog cmd.SyslogConfig
512 }
513
514 func main() {
515 configFile := flag.String("config", "", "File path to the configuration file for this service")
516 flag.Parse()
517 if *configFile == "" {
518 flag.Usage()
519 os.Exit(1)
520 }
521
522 var config Config
523 err := cmd.ReadConfigFile(*configFile, &config)
524 cmd.FailOnError(err, "Reading JSON config file into config structure")
525
526 err = features.Set(config.CertChecker.Features)
527 cmd.FailOnError(err, "Failed to set feature flags")
528
529 syslogger, err := syslog.Dial("", "", syslog.LOG_INFO|syslog.LOG_LOCAL0, "")
530 cmd.FailOnError(err, "Failed to dial syslog")
531
532 syslogLevel := int(syslog.LOG_INFO)
533 if config.Syslog.SyslogLevel != 0 {
534 syslogLevel = config.Syslog.SyslogLevel
535 }
536 logger, err := blog.New(syslogger, config.Syslog.StdoutLevel, syslogLevel)
537 cmd.FailOnError(err, "Could not connect to Syslog")
538
539 err = blog.Set(logger)
540 cmd.FailOnError(err, "Failed to set audit logger")
541
542 logger.Info(cmd.VersionString())
543
544 acceptableValidityDurations := make(map[time.Duration]bool)
545 if len(config.CertChecker.AcceptableValidityDurations) > 0 {
546 for _, entry := range config.CertChecker.AcceptableValidityDurations {
547 acceptableValidityDurations[entry.Duration] = true
548 }
549 } else {
550
551
552 ninetyDays := (time.Hour * 24) * 90
553 acceptableValidityDurations[ninetyDays] = true
554 }
555
556
557 cmd.FailOnError(config.PA.CheckChallenges(), "Invalid PA configuration")
558
559 if config.CertChecker.GoodKey.WeakKeyFile != "" {
560 cmd.Fail("cert-checker does not support checking against weak key files")
561 }
562 if config.CertChecker.GoodKey.BlockedKeyFile != "" {
563 cmd.Fail("cert-checker does not support checking against blocked key files")
564 }
565 kp, err := sagoodkey.NewKeyPolicy(&config.CertChecker.GoodKey, nil)
566 cmd.FailOnError(err, "Unable to create key policy")
567
568 saDbMap, err := sa.InitWrappedDb(config.CertChecker.DB, prometheus.DefaultRegisterer, logger)
569 cmd.FailOnError(err, "While initializing dbMap")
570
571 checkerLatency := prometheus.NewHistogram(prometheus.HistogramOpts{
572 Name: "cert_checker_latency",
573 Help: "Histogram of latencies a cert-checker worker takes to complete a batch",
574 })
575 prometheus.DefaultRegisterer.MustRegister(checkerLatency)
576
577 pa, err := policy.New(config.PA.Challenges, logger)
578 cmd.FailOnError(err, "Failed to create PA")
579
580 err = pa.LoadHostnamePolicyFile(config.CertChecker.HostnamePolicyFile)
581 cmd.FailOnError(err, "Failed to load HostnamePolicyFile")
582
583 if config.CertChecker.CTLogListFile != "" {
584 err = loglist.InitLintList(config.CertChecker.CTLogListFile)
585 cmd.FailOnError(err, "Failed to load CT Log List")
586 }
587
588 checker := newChecker(
589 saDbMap,
590 cmd.Clock(),
591 pa,
592 kp,
593 config.CertChecker.CheckPeriod.Duration,
594 acceptableValidityDurations,
595 logger,
596 )
597 fmt.Fprintf(os.Stderr, "# Getting certificates issued in the last %s\n", config.CertChecker.CheckPeriod)
598
599 ignoredLintsMap := make(map[string]bool)
600 for _, name := range config.CertChecker.IgnoredLints {
601 ignoredLintsMap[name] = true
602 }
603
604
605
606
607 go func() {
608 err := checker.getCerts(context.TODO())
609 cmd.FailOnError(err, "Batch retrieval of certificates failed")
610 }()
611
612 fmt.Fprintf(os.Stderr, "# Processing certificates using %d workers\n", config.CertChecker.Workers)
613 wg := new(sync.WaitGroup)
614 for i := 0; i < config.CertChecker.Workers; i++ {
615 wg.Add(1)
616 go func() {
617 s := checker.clock.Now()
618 checker.processCerts(context.TODO(), wg, config.CertChecker.BadResultsOnly, ignoredLintsMap)
619 checkerLatency.Observe(checker.clock.Since(s).Seconds())
620 }()
621 }
622 wg.Wait()
623 fmt.Fprintf(
624 os.Stderr,
625 "# Finished processing certificates, report length: %d, good: %d, bad: %d\n",
626 len(checker.issuedReport.Entries),
627 checker.issuedReport.GoodCerts,
628 checker.issuedReport.BadCerts,
629 )
630 err = checker.issuedReport.dump()
631 cmd.FailOnError(err, "Failed to dump results: %s\n")
632 }
633
634 func init() {
635 cmd.RegisterCommand("cert-checker", main, &cmd.ConfigValidator{Config: &Config{}})
636 }
637
View as plain text