362 lines
12 KiB
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)
|
|
}
|