boulder/cmd/ocsp-responder/main.go

362 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"crypto"
"crypto/sha1"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/hex"
"errors"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-gorp/gorp/v3"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/ocsp"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/db"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/issuance"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics/measured_http"
bocsp "github.com/letsencrypt/boulder/ocsp"
"github.com/letsencrypt/boulder/sa"
)
// ocspFilter stores information needed to filter OCSP requests (to ensure we
// aren't trying to serve OCSP for certs which aren't ours), and surfaces
// methods to determine if a given request should be filtered or not.
type ocspFilter struct {
issuerKeyHashAlgorithm crypto.Hash
issuerKeyHashes map[issuance.IssuerID][]byte
serialPrefixes []string
}
// newFilter creates a new ocspFilter which will accept a request only if it
// uses the SHA1 algorithm to hash the issuer key, the issuer key matches one
// of the given issuer certs (here, paths to PEM certs on disk), and the serial
// has one of the given prefixes.
func newFilter(issuerCerts []string, serialPrefixes []string) (*ocspFilter, error) {
if len(issuerCerts) < 1 {
return nil, errors.New("Filter must include at least 1 issuer cert")
}
issuerKeyHashes := make(map[issuance.IssuerID][]byte, 0)
for _, issuerCert := range issuerCerts {
// Load the certificate from the file path.
cert, err := core.LoadCert(issuerCert)
if err != nil {
return nil, fmt.Errorf("Could not load issuer cert %s: %w", issuerCert, err)
}
caCert := &issuance.Certificate{Certificate: cert}
// The issuerKeyHash in OCSP requests is constructed over the DER
// encoding of the public key per RFC 6960 (defined in RFC 4055 for
// RSA and RFC 5480 for ECDSA). We can't use MarshalPKIXPublicKey
// for this since it encodes keys using the SPKI structure itself,
// and we just want the contents of the subjectPublicKey for the
// hash, so we need to extract it ourselves.
var spki struct {
Algo pkix.AlgorithmIdentifier
BitString asn1.BitString
}
if _, err := asn1.Unmarshal(caCert.RawSubjectPublicKeyInfo, &spki); err != nil {
return nil, err
}
keyHash := sha1.Sum(spki.BitString.Bytes)
issuerKeyHashes[caCert.ID()] = keyHash[:]
}
return &ocspFilter{crypto.SHA1, issuerKeyHashes, serialPrefixes}, nil
}
// checkRequest returns a descriptive error if the request does not satisfy any of
// the requirements of an OCSP request, or nil if the request should be handled.
func (f *ocspFilter) checkRequest(req *ocsp.Request) error {
if req.HashAlgorithm != f.issuerKeyHashAlgorithm {
return fmt.Errorf("Request ca key hash using unsupported algorithm %s: %w", req.HashAlgorithm, bocsp.ErrNotFound)
}
// Check that this request is for the proper CA
match := false
for _, keyHash := range f.issuerKeyHashes {
if match = bytes.Equal(req.IssuerKeyHash, keyHash); match {
break
}
}
if !match {
return fmt.Errorf("Request intended for wrong issuer cert %s: %w", hex.EncodeToString(req.IssuerKeyHash), bocsp.ErrNotFound)
}
serialString := core.SerialToString(req.SerialNumber)
if len(f.serialPrefixes) > 0 {
match := false
for _, prefix := range f.serialPrefixes {
if match = strings.HasPrefix(serialString, prefix); match {
break
}
}
if !match {
return fmt.Errorf("Request serial has wrong prefix: %w", bocsp.ErrNotFound)
}
}
return nil
}
// responseMatchesIssuer returns true if the CertificateStatus (from the db)
// was generated by an issuer matching the key hash in the original request.
// This filters out, for example, responses which are for a serial that we
// issued, but from a different issuer than that contained in the request.
func (f *ocspFilter) responseMatchesIssuer(req *ocsp.Request, status core.CertificateStatus) bool {
issuerKeyHash, ok := f.issuerKeyHashes[issuance.IssuerID(*status.IssuerID)]
if !ok {
return false
}
return bytes.Equal(issuerKeyHash, req.IssuerKeyHash)
}
// dbSource represents a database containing pre-generated OCSP responses keyed
// by serial number. It also allows for filtering requests by their issuer key
// hash and serial number, to prevent unnecessary lookups for rows that we know
// will not exist in the database.
//
// We assume that OCSP responses are stored in a very simple database table,
// with at least these two columns: serialNumber (TEXT) and response (BLOB).
//
// The serialNumber field may have any type to which Go will match a string,
// so you can be more efficient than TEXT if you like. We use it to store the
// serial number in hex. You must have an index on the serialNumber field,
// since we will always query on it.
type dbSource struct {
dbMap dbSelector
filter *ocspFilter
timeout time.Duration
log blog.Logger
}
// Define an interface with the needed methods from gorp.
// This also allows us to simulate MySQL failures by mocking the interface.
type dbSelector interface {
SelectOne(holder interface{}, query string, args ...interface{}) error
WithContext(ctx context.Context) gorp.SqlExecutor
}
// Response is called by the HTTP server to handle a new OCSP request.
func (src *dbSource) Response(req *ocsp.Request) ([]byte, http.Header, error) {
err := src.filter.checkRequest(req)
if err != nil {
src.log.Debugf("Not responding to filtered OCSP request: %s", err.Error())
return nil, nil, err
}
serialString := core.SerialToString(req.SerialNumber)
src.log.Debugf("Searching for OCSP issued by us for serial %s", serialString)
var certStatus core.CertificateStatus
defer func() {
if len(certStatus.OCSPResponse) != 0 {
src.log.Debugf("OCSP Response sent for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
}
}()
ctx := context.Background()
if src.timeout != 0 {
var cancel func()
ctx, cancel = context.WithTimeout(ctx, src.timeout)
defer cancel()
}
certStatus, err = sa.SelectCertificateStatus(src.dbMap.WithContext(ctx), serialString)
if err != nil {
if db.IsNoRows(err) {
return nil, nil, bocsp.ErrNotFound
}
src.log.AuditErrf("Looking up OCSP response: %s", err)
return nil, nil, err
}
if certStatus.OCSPLastUpdated.IsZero() {
src.log.Warningf("OCSP Response not sent (ocspLastUpdated is zero) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
return nil, nil, bocsp.ErrNotFound
} else if certStatus.IsExpired {
src.log.Warningf("OCSP Response not sent (expired) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
return nil, nil, bocsp.ErrNotFound
} else if !src.filter.responseMatchesIssuer(req, certStatus) {
src.log.Warningf("OCSP Response not sent (issuer and serial mismatch) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
return nil, nil, bocsp.ErrNotFound
}
return certStatus.OCSPResponse, nil, nil
}
type config struct {
OCSPResponder struct {
cmd.ServiceConfig
cmd.DBConfig
// Source indicates the source of pre-signed OCSP responses to be used. It
// can be a DBConnect string or a file URL. The file URL style is used
// when responding from a static file for intermediates and roots.
// If DBConfig has non-empty fields, it takes precedence over this.
Source string
// The list of issuer certificates, against which OCSP requests/responses
// are checked to ensure we're not responding for anyone else's certs.
IssuerCerts []string
Path string
ListenAddress string
// MaxAge is the max-age to set in the Cache-Control response
// header. It is a time.Duration formatted string.
MaxAge cmd.ConfigDuration
// When to timeout a request. This should be slightly lower than the
// upstream's timeout when making request to ocsp-responder.
Timeout cmd.ConfigDuration
ShutdownStopTimeout cmd.ConfigDuration
RequiredSerialPrefixes []string
Features map[string]bool
}
Syslog cmd.SyslogConfig
Common struct {
// TODO(#5162): Remove singular IssuerCert config value.
IssuerCert string
}
}
func main() {
configFile := flag.String("config", "", "File path to the configuration file for this service")
flag.Parse()
if *configFile == "" {
fmt.Fprintf(os.Stderr, `Usage of %s:
Config JSON should contain either a DBConnectFile or a Source value containing a file: URL.
If Source is a file: URL, the file should contain a list of OCSP responses in base64-encoded DER,
as generated by Boulder's ceremony command.
`, os.Args[0])
flag.PrintDefaults()
os.Exit(1)
}
var c config
err := cmd.ReadConfigFile(*configFile, &c)
cmd.FailOnError(err, "Reading JSON config file into config structure")
err = features.Set(c.OCSPResponder.Features)
cmd.FailOnError(err, "Failed to set feature flags")
stats, logger := cmd.StatsAndLogging(c.Syslog, c.OCSPResponder.DebugAddr)
defer logger.AuditPanic()
logger.Info(cmd.VersionString())
config := c.OCSPResponder
var source bocsp.Source
if strings.HasPrefix(config.Source, "file:") {
url, err := url.Parse(config.Source)
cmd.FailOnError(err, "Source was not a URL")
filename := url.Path
// Go interprets cwd-relative file urls (file:test/foo.txt) as having the
// relative part of the path in the 'Opaque' field.
if filename == "" {
filename = url.Opaque
}
source, err = bocsp.NewMemorySourceFromFile(filename, logger)
cmd.FailOnError(err, fmt.Sprintf("Couldn't read file: %s", url.Path))
} else {
// For databases, DBConfig takes precedence over Source, if present.
dbConnect, err := config.DBConfig.URL()
cmd.FailOnError(err, "Reading DB config")
if dbConnect == "" {
dbConnect = config.Source
}
logger.Infof("Loading OCSP Database for CA Cert: %s", c.Common.IssuerCert)
dbSettings := sa.DbSettings{
MaxOpenConns: config.DBConfig.GetMaxOpenConns(),
MaxIdleConns: config.DBConfig.MaxIdleConns,
ConnMaxLifetime: config.DBConfig.ConnMaxLifetime.Duration,
ConnMaxIdleTime: config.DBConfig.ConnMaxIdleTime.Duration,
}
dbMap, err := sa.NewDbMap(dbConnect, dbSettings)
cmd.FailOnError(err, "Could not connect to database")
sa.SetSQLDebug(dbMap, logger)
sa.InitDBMetrics(dbMap, stats, dbSettings)
issuerCerts := c.OCSPResponder.IssuerCerts
if len(issuerCerts) == 0 {
issuerCerts = []string{c.Common.IssuerCert}
}
filter, err := newFilter(issuerCerts, c.OCSPResponder.RequiredSerialPrefixes)
cmd.FailOnError(err, "Couldn't create OCSP filter")
source = &dbSource{dbMap, filter, c.OCSPResponder.Timeout.Duration, logger}
// Export the value for dbSettings.MaxOpenConns
dbConnStat := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "max_db_connections",
Help: "Maximum number of DB connections allowed.",
})
stats.MustRegister(dbConnStat)
dbConnStat.Set(float64(dbSettings.MaxOpenConns))
}
m := mux(stats, c.OCSPResponder.Path, source, logger)
srv := &http.Server{
Addr: c.OCSPResponder.ListenAddress,
Handler: m,
}
done := make(chan bool)
go cmd.CatchSignals(logger, func() {
ctx, cancel := context.WithTimeout(context.Background(),
c.OCSPResponder.ShutdownStopTimeout.Duration)
defer cancel()
_ = srv.Shutdown(ctx)
done <- true
})
err = srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
cmd.FailOnError(err, "Running HTTP server")
}
// https://godoc.org/net/http#Server.Shutdown:
// When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS
// immediately return ErrServerClosed. Make sure the program doesn't exit and
// waits instead for Shutdown to return.
<-done
}
// ocspMux partially implements the interface defined for http.ServeMux but doesn't implement
// the path cleaning its Handler method does. Notably http.ServeMux will collapse repeated
// slashes into a single slash which breaks the base64 encoding that is used in OCSP GET
// requests. ocsp.Responder explicitly recommends against using http.ServeMux
// for this reason.
type ocspMux struct {
handler http.Handler
}
func (om *ocspMux) Handler(_ *http.Request) (http.Handler, string) {
return om.handler, "/"
}
func mux(stats prometheus.Registerer, responderPath string, source bocsp.Source, logger blog.Logger) http.Handler {
stripPrefix := http.StripPrefix(responderPath, bocsp.NewResponder(source, stats, logger))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/" {
w.Header().Set("Cache-Control", "max-age=43200") // Cache for 12 hours
w.WriteHeader(200)
return
}
stripPrefix.ServeHTTP(w, r)
})
return measured_http.New(&ocspMux{h}, cmd.Clock(), stats)
}