Major OCSP refactor (#5863)
Completely refactor the way we organize our code related to OCSP. - Move it all into one `//ocsp/` package, rather than having multiple top-level packages. - Merge the OCSP updater's config sub-package with its parent (since it isn't necessary to break it out to avoid cyclic imports). - Remove all `Source` logic from ocsp-responder's `main.go`, because it was difficult to mentally trace the control flow there. - Replace that logic with a set of composable `Source`s in the `//ocsp/responder/` package, each of which is good at just one thing. - Update the way the filters work to make sure that the request's `IssuerKeyHash` and the response's `ResponderName` can both be derived from the same issuer certificate, ensuring that the req and resp are correctly matched. - Split the metrics into a separate metric for each `Source`, so we can tell what all of them are doing, not just aggregate behavior. - Split the tests into individual files for each `Source`, and update them for the new public interfaces.
This commit is contained in:
parent
cfab636c5a
commit
0a22f83c0b
|
@ -1,11 +1,7 @@
|
|||
package notmain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -14,410 +10,21 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gorp/gorp/v3"
|
||||
"github.com/honeycombio/beeline-go"
|
||||
"github.com/honeycombio/beeline-go/wrappers/hnynethttp"
|
||||
"github.com/jmhodges/clock"
|
||||
|
||||
"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/rocsp"
|
||||
"github.com/letsencrypt/boulder/ocsp/responder"
|
||||
rocsp_config "github.com/letsencrypt/boulder/rocsp/config"
|
||||
"github.com/letsencrypt/boulder/sa"
|
||||
"github.com/letsencrypt/boulder/test/ocsp/helper"
|
||||
)
|
||||
|
||||
// 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
|
||||
// TODO(#5152): Simplify this when we've fully deprecated old-style IssuerIDs.
|
||||
issuerKeyHashes map[issuance.IssuerID][]byte
|
||||
issuerNameKeyHashes map[issuance.IssuerNameID][]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)
|
||||
issuerNameKeyHashes := make(map[issuance.IssuerNameID][]byte)
|
||||
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, err := issuance.NewCertificate(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyHash := caCert.KeyHash()
|
||||
issuerKeyHashes[caCert.ID()] = keyHash[:]
|
||||
issuerNameKeyHashes[caCert.NameID()] = keyHash[:]
|
||||
}
|
||||
return &ocspFilter{crypto.SHA1, issuerKeyHashes, issuerNameKeyHashes, serialPrefixes}, nil
|
||||
}
|
||||
|
||||
// sourceMetrics contain the metrics used to track ocsp lookup errors
|
||||
// between redis and mysql.
|
||||
type sourceMetrics struct {
|
||||
ocspLookups *prometheus.CounterVec
|
||||
}
|
||||
|
||||
func newSourceMetrics(stats prometheus.Registerer) *sourceMetrics {
|
||||
// Metrics for response lookups
|
||||
ocspLookups := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ocsp_lookups",
|
||||
Help: "A counter of ocsp lookups labeled with source and result",
|
||||
}, []string{"source", "result"})
|
||||
stats.MustRegister(ocspLookups)
|
||||
|
||||
metrics := sourceMetrics{
|
||||
ocspLookups: ocspLookups,
|
||||
}
|
||||
return &metrics
|
||||
}
|
||||
|
||||
// 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. We only iterate over
|
||||
// issuerKeyHashes here because it is guaranteed to have the same values
|
||||
// as issuerNameKeyHashes.
|
||||
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.issuerNameKeyHashes[issuance.IssuerNameID(status.IssuerID)]
|
||||
if !ok {
|
||||
// TODO(#5152): Remove this fallback to old-style IssuerIDs.
|
||||
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 {
|
||||
clk clock.Clock
|
||||
primaryLookup ocspLookup
|
||||
secondaryLookup ocspLookup
|
||||
filter *ocspFilter
|
||||
timeout time.Duration
|
||||
log blog.Logger
|
||||
metrics *sourceMetrics
|
||||
}
|
||||
|
||||
// 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 implements the `responder.Source` interface and is called by
|
||||
// the HTTP server to handle a new OCSP request.
|
||||
func (src *dbSource) Response(ctx context.Context, 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 header http.Header = make(map[string][]string)
|
||||
if len(serialString) > 2 {
|
||||
// Set a cache tag that is equal to the last two bytes of the serial.
|
||||
// We expect that to be randomly distributed, so each tag should map to
|
||||
// about 1/256 of our responses.
|
||||
header.Add("Edge-Cache-Tag", serialString[len(serialString)-2:])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
if src.timeout != 0 {
|
||||
var cancel func()
|
||||
ctx, cancel = context.WithTimeout(ctx, src.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// The primary and secondary lookups send goroutines to get an OCSP
|
||||
// status given a serial and return a channel of the output.
|
||||
primaryChan := src.primaryLookup.getResponse(ctx, req)
|
||||
|
||||
// If the redis source is nil, don't try to get a response.
|
||||
var secondaryChan chan lookupResponse
|
||||
if src.secondaryLookup != nil {
|
||||
secondaryChan = src.secondaryLookup.getResponse(ctx, req)
|
||||
}
|
||||
|
||||
// If the primary source returns first, check the output and return
|
||||
// it. If the secondary source wins, then wait for the primary so the
|
||||
// results from the secondary can be verified. It is important that we
|
||||
// never return a response from the redis source that is good if mysql
|
||||
// has a revoked status. If the secondary source wins the race and
|
||||
// passes these checks, return its response instead.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "canceled").Inc()
|
||||
} else {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "deadline_exceeded").Inc()
|
||||
}
|
||||
return nil, nil, fmt.Errorf("looking up OCSP response for serial: %s err: %w", serialString, ctx.Err())
|
||||
case primaryResult := <-primaryChan:
|
||||
if primaryResult.err != nil {
|
||||
if errors.Is(primaryResult.err, bocsp.ErrNotFound) {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "not_found").Inc()
|
||||
} else {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "failed").Inc()
|
||||
}
|
||||
return nil, nil, primaryResult.err
|
||||
}
|
||||
// Parse the OCSP bytes returned from the primary source to check
|
||||
// status, expiration and other fields.
|
||||
primaryParsed, err := ocsp.ParseResponse(primaryResult.bytes, nil)
|
||||
if err != nil {
|
||||
src.log.AuditErrf("parsing OCSP response: %s", err)
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "parse_error").Inc()
|
||||
return nil, nil, err
|
||||
}
|
||||
src.log.Debugf("returning ocsp from primary source: %v", helper.PrettyResponse(primaryParsed))
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "success").Inc()
|
||||
return primaryResult.bytes, header, nil
|
||||
case secondaryResult := <-secondaryChan:
|
||||
// If secondary returns first, wait for primary to return for
|
||||
// comparison.
|
||||
var primaryResult lookupResponse
|
||||
|
||||
// Listen for cancellation or timeout waiting for primary result.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "canceled").Inc()
|
||||
} else {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "deadline_exceeded").Inc()
|
||||
}
|
||||
return nil, nil, fmt.Errorf("looking up OCSP response for serial: %s err: %w", serialString, ctx.Err())
|
||||
case primaryResult = <-primaryChan:
|
||||
}
|
||||
|
||||
// Check for error returned from the mysql lookup, return on error.
|
||||
if primaryResult.err != nil {
|
||||
if errors.Is(primaryResult.err, bocsp.ErrNotFound) {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "not_found").Inc()
|
||||
} else {
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "failed").Inc()
|
||||
}
|
||||
return nil, nil, primaryResult.err
|
||||
}
|
||||
|
||||
// Parse the OCSP bytes returned from the primary source to check
|
||||
// status, expiration and other fields.
|
||||
primaryParsed, err := ocsp.ParseResponse(primaryResult.bytes, nil)
|
||||
if err != nil {
|
||||
src.log.AuditErrf("parsing OCSP response: %s", err)
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "parse_error").Inc()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Check for error returned from the redis lookup. If error return
|
||||
// primary lookup result.
|
||||
if secondaryResult.err != nil {
|
||||
// If we made it this far then there was a successful lookup
|
||||
// on mysql but an error on redis. Either the response exists
|
||||
// in mysql and not in redis or a different error occurred.
|
||||
if errors.Is(secondaryResult.err, bocsp.ErrNotFound) {
|
||||
src.metrics.ocspLookups.WithLabelValues("redis", "not_found").Inc()
|
||||
} else {
|
||||
src.metrics.ocspLookups.WithLabelValues("redis", "failed").Inc()
|
||||
}
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "success").Inc()
|
||||
return primaryResult.bytes, header, nil
|
||||
}
|
||||
|
||||
// Parse the OCSP bytes returned from the secondary source to
|
||||
// compare to primary result.
|
||||
secondaryParsed, err := ocsp.ParseResponse(secondaryResult.bytes, nil)
|
||||
if err != nil {
|
||||
src.log.AuditErrf("parsing secondary OCSP response: %s", err)
|
||||
src.metrics.ocspLookups.WithLabelValues("redis", "parse_error").Inc()
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "success").Inc()
|
||||
return primaryResult.bytes, header, nil
|
||||
}
|
||||
|
||||
// If the secondary response status doesn't match primary return
|
||||
// primary response.
|
||||
if primaryParsed.Status != secondaryParsed.Status {
|
||||
src.metrics.ocspLookups.WithLabelValues("redis", "mismatch").Inc()
|
||||
src.metrics.ocspLookups.WithLabelValues("mysql", "success").Inc()
|
||||
return primaryResult.bytes, header, nil
|
||||
}
|
||||
|
||||
// The secondary response has passed checks, return it.
|
||||
src.metrics.ocspLookups.WithLabelValues("redis", "success").Inc()
|
||||
return secondaryResult.bytes, header, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ocspLookup has a getResponse method that knows how to retrieve an OCSP
|
||||
// response from a datastore and return it or an error in a lookupResponse
|
||||
// object channel
|
||||
type ocspLookup interface {
|
||||
getResponse(context.Context, *ocsp.Request) chan lookupResponse
|
||||
}
|
||||
|
||||
// dbReceiver can get an OCSP response from a mysql database.
|
||||
type dbReceiver struct {
|
||||
dbMap dbSelector
|
||||
filter *ocspFilter
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
// redisReciever can get an OCSP response from a redis datastore.
|
||||
type redisReceiver struct {
|
||||
rocspReader *rocsp.Client
|
||||
}
|
||||
|
||||
// lookupResponse contains an OCSP response in bytes or error.
|
||||
type lookupResponse struct {
|
||||
bytes []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// getResponse implements the ocspLookup interface. Given a context and
|
||||
// `*ocsp.Request`, getResponse will retrieve the appropriate OCSP
|
||||
// response from a mysql database and return it or an error in a
|
||||
// lookupResponse object channel
|
||||
func (src dbReceiver) getResponse(ctx context.Context, req *ocsp.Request) chan lookupResponse {
|
||||
responseChan := make(chan lookupResponse)
|
||||
serialString := core.SerialToString(req.SerialNumber)
|
||||
|
||||
go func() {
|
||||
defer close(responseChan)
|
||||
certStatus, err := sa.SelectCertificateStatus(src.dbMap.WithContext(ctx), serialString)
|
||||
if err != nil {
|
||||
if db.IsNoRows(err) {
|
||||
responseChan <- lookupResponse{nil, bocsp.ErrNotFound}
|
||||
return
|
||||
}
|
||||
src.log.AuditErrf("Looking up OCSP response in DB: %s", err)
|
||||
|
||||
responseChan <- lookupResponse{nil, err}
|
||||
return
|
||||
}
|
||||
|
||||
if certStatus.IsExpired {
|
||||
src.log.Infof("OCSP Response not sent (expired) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
|
||||
responseChan <- lookupResponse{nil, bocsp.ErrNotFound}
|
||||
return
|
||||
} else if certStatus.OCSPLastUpdated.IsZero() {
|
||||
src.log.Warningf("OCSP Response not sent (ocspLastUpdated is zero) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
|
||||
responseChan <- lookupResponse{nil, bocsp.ErrNotFound}
|
||||
return
|
||||
} 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)
|
||||
responseChan <- lookupResponse{nil, bocsp.ErrNotFound}
|
||||
return
|
||||
}
|
||||
responseChan <- lookupResponse{certStatus.OCSPResponse, err}
|
||||
|
||||
}()
|
||||
|
||||
return responseChan
|
||||
}
|
||||
|
||||
// getResponse implements the ocspLookup interface. Given a context and
|
||||
// `*ocsp.Request`, getResponse will retrieve the appropriate OCSP
|
||||
// response from a redis datastore and return it or an error in a
|
||||
// lookupResponse object channel.
|
||||
func (src redisReceiver) getResponse(ctx context.Context, req *ocsp.Request) chan lookupResponse {
|
||||
responseChan := make(chan lookupResponse)
|
||||
serialString := core.SerialToString(req.SerialNumber)
|
||||
|
||||
go func() {
|
||||
defer close(responseChan)
|
||||
respBytes, err := src.rocspReader.GetResponse(ctx, serialString)
|
||||
if errors.Is(err, rocsp.ErrRedisNotFound) {
|
||||
responseChan <- lookupResponse{nil, bocsp.ErrNotFound}
|
||||
return
|
||||
}
|
||||
responseChan <- lookupResponse{respBytes, err}
|
||||
}()
|
||||
|
||||
return responseChan
|
||||
}
|
||||
|
||||
// Make sure that dbReceiver and redisReceiver implements ocspLookup if it
|
||||
// does not, this will error at compile time.
|
||||
var _ ocspLookup = (*dbReceiver)(nil)
|
||||
var _ ocspLookup = (*redisReceiver)(nil)
|
||||
|
||||
type Config struct {
|
||||
OCSPResponder struct {
|
||||
cmd.ServiceConfig
|
||||
|
@ -487,7 +94,7 @@ as generated by Boulder's ceremony command.
|
|||
logger.Info(cmd.VersionString())
|
||||
|
||||
config := c.OCSPResponder
|
||||
var source bocsp.Source
|
||||
var source responder.Source
|
||||
|
||||
if strings.HasPrefix(config.Source, "file:") {
|
||||
url, err := url.Parse(config.Source)
|
||||
|
@ -498,7 +105,7 @@ as generated by Boulder's ceremony command.
|
|||
if filename == "" {
|
||||
filename = url.Opaque
|
||||
}
|
||||
source, err = bocsp.NewMemorySourceFromFile(filename, logger)
|
||||
source, err = responder.NewMemorySourceFromFile(filename, logger)
|
||||
cmd.FailOnError(err, fmt.Sprintf("Couldn't read file: %s", url.Path))
|
||||
} else {
|
||||
// Set DB.DBConnect as a fallback if DB.DBConnectFile isn't present.
|
||||
|
@ -507,39 +114,41 @@ as generated by Boulder's ceremony command.
|
|||
dbMap, err := sa.InitWrappedDb(config.DB, stats, logger)
|
||||
cmd.FailOnError(err, "While initializing dbMap")
|
||||
|
||||
issuerCerts := c.OCSPResponder.IssuerCerts
|
||||
source, err = responder.NewDbSource(dbMap, stats, logger)
|
||||
cmd.FailOnError(err, "Could not create database source")
|
||||
|
||||
filter, err := newFilter(issuerCerts, c.OCSPResponder.RequiredSerialPrefixes)
|
||||
cmd.FailOnError(err, "Couldn't create OCSP filter")
|
||||
|
||||
pLookup := dbReceiver{dbMap, filter, logger}
|
||||
|
||||
// Set up the redis source if there is a config. Otherwise just
|
||||
// set up a mysql source.
|
||||
var redisLookup ocspLookup
|
||||
// Set up the redis source and the combined multiplex source if there is a
|
||||
// config for it. Otherwise just pass through the existing mysql source.
|
||||
if c.OCSPResponder.Redis.Addrs != nil {
|
||||
logger.Info("redis config found, configuring redis reader")
|
||||
rocspReader, err := rocsp_config.MakeReadClient(&c.OCSPResponder.Redis, clk, stats)
|
||||
if err != nil {
|
||||
cmd.FailOnError(err, "could not make redis client")
|
||||
}
|
||||
redisLookup = redisReceiver{rocspReader}
|
||||
} else {
|
||||
logger.Info("no redis config found, using mysql as only ocsp source")
|
||||
cmd.FailOnError(err, "Could not make redis client")
|
||||
|
||||
rocspSource, err := responder.NewRedisSource(rocspReader, stats, logger)
|
||||
cmd.FailOnError(err, "Could not create redis source")
|
||||
|
||||
source, err = responder.NewMultiSource(source, rocspSource, stats, logger)
|
||||
cmd.FailOnError(err, "Could not create multiplex source")
|
||||
}
|
||||
|
||||
source = &dbSource{
|
||||
clk: clk,
|
||||
primaryLookup: pLookup,
|
||||
secondaryLookup: redisLookup,
|
||||
filter: filter,
|
||||
timeout: c.OCSPResponder.Timeout.Duration,
|
||||
log: logger,
|
||||
metrics: newSourceMetrics(stats),
|
||||
// Load the certificate from the file path.
|
||||
issuerCerts := make([]*issuance.Certificate, len(c.OCSPResponder.IssuerCerts))
|
||||
for i, issuerFile := range c.OCSPResponder.IssuerCerts {
|
||||
issuerCert, err := issuance.LoadCertificate(issuerFile)
|
||||
cmd.FailOnError(err, "Could not load issuer cert")
|
||||
issuerCerts[i] = issuerCert
|
||||
}
|
||||
|
||||
source, err = responder.NewFilterSource(
|
||||
issuerCerts,
|
||||
c.OCSPResponder.RequiredSerialPrefixes,
|
||||
source,
|
||||
stats,
|
||||
logger,
|
||||
)
|
||||
cmd.FailOnError(err, "Could not create filtered source")
|
||||
}
|
||||
|
||||
m := mux(stats, c.OCSPResponder.Path, source, logger)
|
||||
m := mux(c.OCSPResponder.Path, source, c.OCSPResponder.Timeout.Duration, stats, logger)
|
||||
srv := &http.Server{
|
||||
Addr: c.OCSPResponder.ListenAddress,
|
||||
Handler: m,
|
||||
|
@ -579,8 +188,8 @@ 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))
|
||||
func mux(responderPath string, source responder.Source, timeout time.Duration, stats prometheus.Registerer, logger blog.Logger) http.Handler {
|
||||
stripPrefix := http.StripPrefix(responderPath, responder.NewResponder(source, timeout, 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
|
||||
|
|
|
@ -2,79 +2,46 @@ package notmain
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gorp/gorp/v3"
|
||||
"github.com/jmhodges/clock"
|
||||
|
||||
"golang.org/x/crypto/ocsp"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/issuance"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/metrics"
|
||||
bocsp "github.com/letsencrypt/boulder/ocsp"
|
||||
"github.com/letsencrypt/boulder/ocsp/responder"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
)
|
||||
|
||||
var (
|
||||
issuerID = int64(3568119531)
|
||||
req = mustRead("./testdata/ocsp.req")
|
||||
resp = core.CertificateStatus{
|
||||
OCSPResponse: mustRead("./testdata/ocsp.resp"),
|
||||
IsExpired: false,
|
||||
OCSPLastUpdated: time.Now(),
|
||||
IssuerID: issuerID,
|
||||
}
|
||||
stats = metrics.NoopRegisterer
|
||||
)
|
||||
|
||||
func mustRead(path string) []byte {
|
||||
b, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("read %#v: %s", path, err))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (clock.FakeClock, *blog.Mock, *sourceMetrics) {
|
||||
fc := clock.NewFake()
|
||||
fc.Add(1 * time.Hour)
|
||||
logger := blog.NewMock()
|
||||
metrics := newSourceMetrics(metrics.NoopRegisterer)
|
||||
return fc, logger, metrics
|
||||
}
|
||||
|
||||
func TestMux(t *testing.T) {
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ocsp.ParseRequest: %s", err)
|
||||
}
|
||||
reqBytes, err := ioutil.ReadFile("./testdata/ocsp.req")
|
||||
test.AssertNotError(t, err, "failed to read OCSP request")
|
||||
req, err := ocsp.ParseRequest(reqBytes)
|
||||
test.AssertNotError(t, err, "failed to parse OCSP request")
|
||||
|
||||
doubleSlashBytes, err := base64.StdEncoding.DecodeString("MFMwUTBPME0wSzAJBgUrDgMCGgUABBR+5mrncpqz/PiiIGRsFqEtYHEIXQQUqEpqYwR93brm0Tm3pkVl7/Oo7KECEgO/AC2R1FW8hePAj4xp//8Jhw==")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode double slash OCSP request")
|
||||
}
|
||||
test.AssertNotError(t, err, "failed to decode double slash OCSP request")
|
||||
doubleSlashReq, err := ocsp.ParseRequest(doubleSlashBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse double slash OCSP request")
|
||||
test.AssertNotError(t, err, "failed to parse double slash OCSP request")
|
||||
|
||||
respBytes, err := ioutil.ReadFile("./testdata/ocsp.resp")
|
||||
test.AssertNotError(t, err, "failed to read OCSP response")
|
||||
resp, err := ocsp.ParseResponse(respBytes, nil)
|
||||
test.AssertNotError(t, err, "failed to parse OCSP response")
|
||||
|
||||
responses := map[string]*responder.Response{
|
||||
req.SerialNumber.String(): {Response: resp, Raw: respBytes},
|
||||
doubleSlashReq.SerialNumber.String(): {Response: resp, Raw: respBytes},
|
||||
}
|
||||
responses := map[string][]byte{
|
||||
ocspReq.SerialNumber.String(): resp.OCSPResponse,
|
||||
doubleSlashReq.SerialNumber.String(): resp.OCSPResponse,
|
||||
}
|
||||
src := bocsp.NewMemorySource(responses, blog.NewMock())
|
||||
h := mux(stats, "/foobar/", src, blog.NewMock())
|
||||
src, err := responder.NewMemorySource(responses, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create inMemorySource")
|
||||
|
||||
h := mux("/foobar/", src, time.Second, metrics.NoopRegisterer, blog.NewMock())
|
||||
|
||||
type muxTest struct {
|
||||
method string
|
||||
path string
|
||||
|
@ -83,9 +50,9 @@ func TestMux(t *testing.T) {
|
|||
expectedType string
|
||||
}
|
||||
mts := []muxTest{
|
||||
{"POST", "/foobar/", req, resp.OCSPResponse, "Success"},
|
||||
{"POST", "/foobar/", reqBytes, respBytes, "Success"},
|
||||
{"GET", "/", nil, nil, ""},
|
||||
{"GET", "/foobar/MFMwUTBPME0wSzAJBgUrDgMCGgUABBR+5mrncpqz/PiiIGRsFqEtYHEIXQQUqEpqYwR93brm0Tm3pkVl7/Oo7KECEgO/AC2R1FW8hePAj4xp//8Jhw==", nil, resp.OCSPResponse, "Success"},
|
||||
{"GET", "/foobar/MFMwUTBPME0wSzAJBgUrDgMCGgUABBR+5mrncpqz/PiiIGRsFqEtYHEIXQQUqEpqYwR93brm0Tm3pkVl7/Oo7KECEgO/AC2R1FW8hePAj4xp//8Jhw==", nil, respBytes, "Success"},
|
||||
}
|
||||
for i, mt := range mts {
|
||||
w := httptest.NewRecorder()
|
||||
|
@ -102,354 +69,3 @@ func TestMux(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFilter(t *testing.T) {
|
||||
_, err := newFilter([]string{}, []string{})
|
||||
test.AssertError(t, err, "Didn't error when creating empty filter")
|
||||
|
||||
_, err = newFilter([]string{"/tmp/doesnotexist.foo"}, []string{})
|
||||
test.AssertError(t, err, "Didn't error on non-existent issuer cert")
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, []string{"00"})
|
||||
test.AssertNotError(t, err, "Errored when creating good filter")
|
||||
test.AssertEquals(t, len(f.issuerKeyHashes), 1)
|
||||
test.AssertEquals(t, len(f.serialPrefixes), 1)
|
||||
test.AssertEquals(t, hex.EncodeToString(f.issuerKeyHashes[issuance.IssuerID(issuerID)]), "fb784f12f96015832c9f177f3419b32e36ea4189")
|
||||
}
|
||||
|
||||
func TestCheckRequest(t *testing.T) {
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, []string{"00"})
|
||||
test.AssertNotError(t, err, "Errored when creating good filter")
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to prepare fake ocsp request")
|
||||
test.AssertNotError(t, f.checkRequest(ocspReq), "Rejected good ocsp request with bad hash algorithm")
|
||||
|
||||
ocspReq, err = ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to prepare fake ocsp request")
|
||||
// Select a bad hash algorithm.
|
||||
ocspReq.HashAlgorithm = crypto.MD5
|
||||
test.AssertError(t, f.checkRequest((ocspReq)), "Accepted ocsp request with bad hash algorithm")
|
||||
|
||||
ocspReq, err = ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to prepare fake ocsp request")
|
||||
// Make the hash invalid.
|
||||
ocspReq.IssuerKeyHash[0]++
|
||||
test.AssertError(t, f.checkRequest(ocspReq), "Accepted ocsp request with bad issuer key hash")
|
||||
|
||||
ocspReq, err = ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to prepare fake ocsp request")
|
||||
// Make the serial prefix wrong by incrementing the first byte by 1.
|
||||
serialStr := []byte(core.SerialToString(ocspReq.SerialNumber))
|
||||
serialStr[0] = serialStr[0] + 1
|
||||
ocspReq.SerialNumber.SetString(string(serialStr), 16)
|
||||
test.AssertError(t, f.checkRequest(ocspReq), "Accepted ocsp request with bad serial prefix")
|
||||
}
|
||||
|
||||
func TestResponseMatchesIssuer(t *testing.T) {
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, []string{"00"})
|
||||
test.AssertNotError(t, err, "Errored when creating good filter")
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to prepare fake ocsp request")
|
||||
test.AssertEquals(t, f.responseMatchesIssuer(ocspReq, resp), true)
|
||||
|
||||
ocspReq, err = ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to prepare fake ocsp request")
|
||||
fakeID := int64(123456)
|
||||
ocspResp := core.CertificateStatus{
|
||||
OCSPResponse: mustRead("./testdata/ocsp.resp"),
|
||||
IsExpired: false,
|
||||
OCSPLastUpdated: time.Now(),
|
||||
IssuerID: fakeID,
|
||||
}
|
||||
test.AssertEquals(t, f.responseMatchesIssuer(ocspReq, ocspResp), false)
|
||||
}
|
||||
|
||||
func TestDBHandler(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, nil)
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
db := dbReceiver{mockSelector{}, f, mockLog}
|
||||
src := &dbSource{fc, db, nil, f, time.Second, mockLog, metrics}
|
||||
|
||||
h := bocsp.NewResponder(src, stats, mockLog)
|
||||
w := httptest.NewRecorder()
|
||||
r, err := http.NewRequest("POST", "/", bytes.NewReader(req))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Code: want %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
cacheTag := w.Result().Header["Edge-Cache-Tag"]
|
||||
expectedCacheTag := []string{"08"}
|
||||
if !reflect.DeepEqual(cacheTag, expectedCacheTag) {
|
||||
t.Errorf("Edge-Cache-Tag: expected %q, got %q", expectedCacheTag, cacheTag)
|
||||
}
|
||||
if !bytes.Equal(w.Body.Bytes(), resp.OCSPResponse) {
|
||||
t.Errorf("Mismatched body: want %#v, got %#v", resp, w.Body.Bytes())
|
||||
}
|
||||
|
||||
// check response with zero OCSPLastUpdated is ignored
|
||||
resp.OCSPLastUpdated = time.Time{}
|
||||
defer func() { resp.OCSPLastUpdated = time.Now() }()
|
||||
w = httptest.NewRecorder()
|
||||
r, _ = http.NewRequest("POST", "/", bytes.NewReader(req))
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Code: want %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
if !bytes.Equal(w.Body.Bytes(), ocsp.UnauthorizedErrorResponse) {
|
||||
t.Errorf("Mismatched body: want %#v, got %#v", ocsp.UnauthorizedErrorResponse, w.Body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// mockSelector always returns the same certificateStatus
|
||||
type mockSelector struct {
|
||||
mockSqlExecutor
|
||||
}
|
||||
|
||||
func (bs mockSelector) WithContext(context.Context) gorp.SqlExecutor {
|
||||
return bs
|
||||
}
|
||||
|
||||
func (bs mockSelector) SelectOne(output interface{}, _ string, _ ...interface{}) error {
|
||||
outputPtr, ok := output.(*core.CertificateStatus)
|
||||
if !ok {
|
||||
return fmt.Errorf("incorrect output type %T", output)
|
||||
}
|
||||
*outputPtr = resp
|
||||
return nil
|
||||
}
|
||||
|
||||
// To mock out WithContext, we need to be able to return objects that satisfy
|
||||
// gorp.SqlExecutor. That's a pretty big interface, so we specify one no-op mock
|
||||
// that we can embed everywhere we need to satisfy it.
|
||||
// Note: mockSqlExecutor does *not* implement WithContext. The expectation is
|
||||
// that structs that embed mockSqlExecutor will define their own WithContext
|
||||
// that returns a reference to themselves. That makes it easy for those structs
|
||||
// to override the specific methods they need to implement (e.g. SelectOne).
|
||||
type mockSqlExecutor struct{}
|
||||
|
||||
func (mse mockSqlExecutor) Get(i interface{}, keys ...interface{}) (interface{}, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Insert(list ...interface{}) error {
|
||||
return fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Update(list ...interface{}) (int64, error) {
|
||||
return 0, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Delete(list ...interface{}) (int64, error) {
|
||||
return 0, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Select(i interface{}, query string, args ...interface{}) ([]interface{}, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectInt(query string, args ...interface{}) (int64, error) {
|
||||
return 0, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectNullInt(query string, args ...interface{}) (sql.NullInt64, error) {
|
||||
return sql.NullInt64{}, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectFloat(query string, args ...interface{}) (float64, error) {
|
||||
return 0, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectNullFloat(query string, args ...interface{}) (sql.NullFloat64, error) {
|
||||
return sql.NullFloat64{}, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectStr(query string, args ...interface{}) (string, error) {
|
||||
return "", fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectNullStr(query string, args ...interface{}) (sql.NullString, error) {
|
||||
return sql.NullString{}, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectOne(holder interface{}, query string, args ...interface{}) error {
|
||||
return fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
return nil, fmt.Errorf("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
return nil
|
||||
}
|
||||
|
||||
// brokenSelector allows us to test what happens when gorp SelectOne statements
|
||||
// throw errors and satisfies the dbSelector interface
|
||||
type brokenSelector struct {
|
||||
mockSqlExecutor
|
||||
}
|
||||
|
||||
func (bs brokenSelector) SelectOne(_ interface{}, _ string, _ ...interface{}) error {
|
||||
return fmt.Errorf("Failure!")
|
||||
}
|
||||
|
||||
func (bs brokenSelector) WithContext(context.Context) gorp.SqlExecutor {
|
||||
return bs
|
||||
}
|
||||
|
||||
func TestErrorLog(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, nil)
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
db := dbReceiver{brokenSelector{}, f, mockLog}
|
||||
src := &dbSource{fc, db, nil, f, time.Second, mockLog, metrics}
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertError(t, err, "expected error")
|
||||
test.AssertEquals(t, err.Error(), "Failure!")
|
||||
|
||||
test.AssertEquals(t, len(mockLog.GetAllMatching("Looking up OCSP response")), 1)
|
||||
}
|
||||
|
||||
func TestRequiredSerialPrefix(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, []string{"nope"})
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
db := dbReceiver{mockSelector{}, f, mockLog}
|
||||
src := &dbSource{fc, db, nil, f, time.Second, mockLog, metrics}
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertErrorIs(t, err, bocsp.ErrNotFound)
|
||||
|
||||
fmt.Println(core.SerialToString(ocspReq.SerialNumber))
|
||||
|
||||
f, err = newFilter([]string{"./testdata/test-ca.der.pem"}, []string{"00", "nope"})
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
src = &dbSource{fc, db, nil, f, time.Second, mockLog, metrics}
|
||||
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertNotError(t, err, "src.Response failed with acceptable prefix")
|
||||
}
|
||||
|
||||
type expiredSelector struct {
|
||||
mockSqlExecutor
|
||||
}
|
||||
|
||||
func (es expiredSelector) SelectOne(obj interface{}, _ string, _ ...interface{}) error {
|
||||
rows := obj.(*core.CertificateStatus)
|
||||
rows.IsExpired = true
|
||||
rows.OCSPLastUpdated = time.Time{}.Add(time.Hour)
|
||||
issuerID = int64(123456)
|
||||
rows.IssuerID = issuerID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es expiredSelector) WithContext(context.Context) gorp.SqlExecutor {
|
||||
return es
|
||||
}
|
||||
|
||||
func TestExpiredUnauthorized(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, []string{"00"})
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
db := dbReceiver{expiredSelector{}, f, mockLog}
|
||||
src := &dbSource{fc, db, nil, f, time.Second, mockLog, metrics}
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertErrorIs(t, err, bocsp.ErrNotFound)
|
||||
}
|
||||
|
||||
type alwaysSucceedLookup struct{}
|
||||
|
||||
func (src *alwaysSucceedLookup) getResponse(context.Context, *ocsp.Request) chan lookupResponse {
|
||||
responseChan := make(chan lookupResponse, 1)
|
||||
defer close(responseChan)
|
||||
responseChan <- lookupResponse{resp.OCSPResponse, nil}
|
||||
return responseChan
|
||||
}
|
||||
|
||||
type alwaysErrLookup struct{}
|
||||
|
||||
func (src *alwaysErrLookup) getResponse(context.Context, *ocsp.Request) chan lookupResponse {
|
||||
responseChan := make(chan lookupResponse, 1)
|
||||
defer close(responseChan)
|
||||
responseChan <- lookupResponse{nil, fmt.Errorf("Failure!")}
|
||||
return responseChan
|
||||
}
|
||||
|
||||
type alwaysBlockLookup struct{}
|
||||
|
||||
func (src *alwaysBlockLookup) getResponse(context.Context, *ocsp.Request) chan lookupResponse {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetResponsePrimaryGoodSecondaryErr(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, nil)
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
src := &dbSource{fc, &alwaysSucceedLookup{}, &alwaysErrLookup{}, f, time.Second, mockLog, metrics}
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
}
|
||||
|
||||
func TestGetResponsePrimaryErrSecondaryGood(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, nil)
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
src := &dbSource{fc, &alwaysErrLookup{}, &alwaysSucceedLookup{}, f, time.Second, mockLog, metrics}
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
||||
|
||||
func TestGetResponsePrimaryTimeoutSecondaryGood(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, nil)
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
src := &dbSource{fc, &alwaysBlockLookup{}, &alwaysSucceedLookup{}, f, time.Second, mockLog, metrics}
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
||||
|
||||
func TestGetResponsePrimaryGoodSecondaryTimeout(t *testing.T) {
|
||||
fc, mockLog, metrics := setup(t)
|
||||
|
||||
f, err := newFilter([]string{"./testdata/test-ca.der.pem"}, nil)
|
||||
test.AssertNotError(t, err, "newFilter")
|
||||
|
||||
ocspReq, err := ocsp.ParseRequest(req)
|
||||
test.AssertNotError(t, err, "Failed to parse OCSP request")
|
||||
|
||||
src := &dbSource{fc, &alwaysSucceedLookup{}, &alwaysBlockLookup{}, f, time.Second, mockLog, metrics}
|
||||
_, _, err = src.Response(context.Background(), ocspReq)
|
||||
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
}
|
||||
|
|
|
@ -12,15 +12,14 @@ import (
|
|||
"github.com/letsencrypt/boulder/cmd"
|
||||
"github.com/letsencrypt/boulder/features"
|
||||
bgrpc "github.com/letsencrypt/boulder/grpc"
|
||||
"github.com/letsencrypt/boulder/ocsp_updater"
|
||||
ocsp_updater_config "github.com/letsencrypt/boulder/ocsp_updater/config"
|
||||
ocsp_updater "github.com/letsencrypt/boulder/ocsp/updater"
|
||||
"github.com/letsencrypt/boulder/rocsp"
|
||||
rocsp_config "github.com/letsencrypt/boulder/rocsp/config"
|
||||
"github.com/letsencrypt/boulder/sa"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
OCSPUpdater ocsp_updater_config.Config
|
||||
OCSPUpdater ocsp_updater.Config
|
||||
|
||||
Syslog cmd.SyslogConfig
|
||||
Beeline cmd.BeelineConfig
|
||||
|
@ -96,6 +95,7 @@ func main() {
|
|||
serialSuffixes,
|
||||
ogc,
|
||||
// Necessary evil for now
|
||||
// TODO(XXX): Fix this, or file a bug to fix it later.
|
||||
conf,
|
||||
logger,
|
||||
)
|
||||
|
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/letsencrypt/boulder/policyasn1"
|
||||
"github.com/letsencrypt/boulder/privatekey"
|
||||
"github.com/letsencrypt/pkcs11key/v4"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
// ProfileConfig describes the certificate issuance constraints for all issuers.
|
||||
|
@ -428,6 +429,15 @@ func GetIssuerNameID(ee *x509.Certificate) IssuerNameID {
|
|||
return truncatedHash(ee.RawIssuer)
|
||||
}
|
||||
|
||||
// GetOCSPIssuerNameID returns the IssuerNameID (a truncated hash over the raw
|
||||
// bytes of the Responder Distinguished Name) of the given OCSP Response.
|
||||
// As per the OCSP spec, it is technically possible for this field to not be
|
||||
// populated: the OCSP Response can instead contain a SHA-1 hash of the Issuer
|
||||
// Public Key as the Responder ID. The Go stdlib always uses the DN, though.
|
||||
func GetOCSPIssuerNameID(resp *ocsp.Response) IssuerNameID {
|
||||
return truncatedHash(resp.RawResponderName)
|
||||
}
|
||||
|
||||
// truncatedHash computes a truncated SHA1 hash across arbitrary bytes. Uses
|
||||
// SHA1 because that is the algorithm most commonly used in OCSP requests.
|
||||
// PURPOSEFULLY NOT EXPORTED. Exists only to ensure that the implementations of
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/go-gorp/gorp/v3"
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/db"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/sa"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
type dbSource struct {
|
||||
dbMap dbSelector
|
||||
counter *prometheus.CounterVec
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
// dbSelector is a limited subset of the db.WrappedMap interface to allow for
|
||||
// easier mocking of mysql operations in tests.
|
||||
type dbSelector interface {
|
||||
SelectOne(holder interface{}, query string, args ...interface{}) error
|
||||
WithContext(ctx context.Context) gorp.SqlExecutor
|
||||
}
|
||||
|
||||
// NewDbSource returns a dbSource which will look up OCSP responses in a SQL
|
||||
// database.
|
||||
func NewDbSource(dbMap dbSelector, stats prometheus.Registerer, log blog.Logger) (*dbSource, error) {
|
||||
counter := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ocsp_db_responses",
|
||||
Help: "Count of OCSP requests/responses by action taken by the dbSource",
|
||||
}, []string{"result"})
|
||||
return &dbSource{
|
||||
dbMap: dbMap,
|
||||
counter: counter,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Response implements the Source interface. It looks up the requested OCSP
|
||||
// response in the sql database. If the certificate status row that it finds
|
||||
// indicates that the cert is expired or this cert has never had an OCSP
|
||||
// response generated for it, it returns an error.
|
||||
func (src *dbSource) Response(ctx context.Context, req *ocsp.Request) (*Response, error) {
|
||||
serialString := core.SerialToString(req.SerialNumber)
|
||||
|
||||
certStatus, err := sa.SelectCertificateStatus(src.dbMap.WithContext(ctx), serialString)
|
||||
if err != nil {
|
||||
if db.IsNoRows(err) {
|
||||
src.counter.WithLabelValues("not_found").Inc()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
src.log.AuditErrf("Looking up OCSP response in DB: %s", err)
|
||||
src.counter.WithLabelValues("lookup_error").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if certStatus.IsExpired {
|
||||
src.log.Infof("OCSP Response not sent (expired) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
|
||||
src.counter.WithLabelValues("expired").Inc()
|
||||
return nil, ErrNotFound
|
||||
} else if certStatus.OCSPLastUpdated.IsZero() {
|
||||
src.log.Warningf("OCSP Response not sent (ocspLastUpdated is zero) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), serialString)
|
||||
src.counter.WithLabelValues("never_updated").Inc()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
resp, err := ocsp.ParseResponse(certStatus.OCSPResponse, nil)
|
||||
if err != nil {
|
||||
src.counter.WithLabelValues("parse_error").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src.counter.WithLabelValues("success").Inc()
|
||||
return &Response{Response: resp, Raw: certStatus.OCSPResponse}, nil
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gorp/gorp/v3"
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/db"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/metrics"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
// To mock out WithContext, we need to be able to return objects that satisfy
|
||||
// gorp.SqlExecutor. That's a pretty big interface, so we specify one no-op mock
|
||||
// that we can embed everywhere we need to satisfy it.
|
||||
// Note: mockSqlExecutor does *not* implement WithContext. The expectation is
|
||||
// that structs that embed mockSqlExecutor will define their own WithContext
|
||||
// that returns a reference to themselves. That makes it easy for those structs
|
||||
// to override the specific methods they need to implement (e.g. SelectOne).
|
||||
type mockSqlExecutor struct{}
|
||||
|
||||
func (mse mockSqlExecutor) Get(i interface{}, keys ...interface{}) (interface{}, error) {
|
||||
return nil, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Insert(list ...interface{}) error {
|
||||
return errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Update(list ...interface{}) (int64, error) {
|
||||
return 0, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Delete(list ...interface{}) (int64, error) {
|
||||
return 0, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return nil, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Select(i interface{}, query string, args ...interface{}) ([]interface{}, error) {
|
||||
return nil, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectInt(query string, args ...interface{}) (int64, error) {
|
||||
return 0, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectNullInt(query string, args ...interface{}) (sql.NullInt64, error) {
|
||||
return sql.NullInt64{}, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectFloat(query string, args ...interface{}) (float64, error) {
|
||||
return 0, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectNullFloat(query string, args ...interface{}) (sql.NullFloat64, error) {
|
||||
return sql.NullFloat64{}, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectStr(query string, args ...interface{}) (string, error) {
|
||||
return "", errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectNullStr(query string, args ...interface{}) (sql.NullString, error) {
|
||||
return sql.NullString{}, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) SelectOne(holder interface{}, query string, args ...interface{}) error {
|
||||
return errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
return nil, errors.New("unimplemented")
|
||||
}
|
||||
func (mse mockSqlExecutor) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
return nil
|
||||
}
|
||||
|
||||
// echoSelector always returns the given certificateStatus.
|
||||
type echoSelector struct {
|
||||
mockSqlExecutor
|
||||
status core.CertificateStatus
|
||||
}
|
||||
|
||||
func (s echoSelector) WithContext(context.Context) gorp.SqlExecutor {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s echoSelector) SelectOne(output interface{}, _ string, _ ...interface{}) error {
|
||||
outputPtr, ok := output.(*core.CertificateStatus)
|
||||
if !ok {
|
||||
return fmt.Errorf("incorrect output type %T", output)
|
||||
}
|
||||
*outputPtr = s.status
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorSelector always returns the given error.
|
||||
type errorSelector struct {
|
||||
mockSqlExecutor
|
||||
err error
|
||||
}
|
||||
|
||||
func (s errorSelector) SelectOne(_ interface{}, _ string, _ ...interface{}) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s errorSelector) WithContext(context.Context) gorp.SqlExecutor {
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDbSource(t *testing.T) {
|
||||
reqBytes, err := ioutil.ReadFile("./testdata/ocsp.req")
|
||||
test.AssertNotError(t, err, "failed to read OCSP request")
|
||||
req, err := ocsp.ParseRequest(reqBytes)
|
||||
test.AssertNotError(t, err, "failed to parse OCSP request")
|
||||
|
||||
respBytes, err := ioutil.ReadFile("./testdata/ocsp.resp")
|
||||
test.AssertNotError(t, err, "failed to read OCSP response")
|
||||
|
||||
// Test for failure when the database lookup fails.
|
||||
dbErr := errors.New("something went wrong")
|
||||
src, err := NewDbSource(errorSelector{err: dbErr}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create dbSource")
|
||||
_, err = src.Response(context.Background(), req)
|
||||
test.AssertEquals(t, err, dbErr)
|
||||
|
||||
// Test for graceful recovery when the database returns no results.
|
||||
dbErr = db.ErrDatabaseOp{
|
||||
Op: "test",
|
||||
Table: "certificateStatus",
|
||||
Err: sql.ErrNoRows,
|
||||
}
|
||||
src, err = NewDbSource(errorSelector{err: dbErr}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create dbSource")
|
||||
_, err = src.Response(context.Background(), req)
|
||||
test.AssertErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// Test for converting expired results into no results.
|
||||
status := core.CertificateStatus{
|
||||
IsExpired: true,
|
||||
}
|
||||
src, err = NewDbSource(echoSelector{status: status}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create dbSource")
|
||||
_, err = src.Response(context.Background(), req)
|
||||
test.AssertErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// Test for converting never-updated results into no results.
|
||||
status = core.CertificateStatus{
|
||||
IsExpired: false,
|
||||
OCSPLastUpdated: time.Time{},
|
||||
}
|
||||
src, err = NewDbSource(echoSelector{status: status}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create dbSource")
|
||||
_, err = src.Response(context.Background(), req)
|
||||
test.AssertErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// Test for reporting parse errors.
|
||||
status = core.CertificateStatus{
|
||||
IsExpired: false,
|
||||
OCSPLastUpdated: time.Now(),
|
||||
OCSPResponse: respBytes[1:],
|
||||
}
|
||||
src, err = NewDbSource(echoSelector{status: status}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create dbSource")
|
||||
_, err = src.Response(context.Background(), req)
|
||||
test.AssertError(t, err, "expected failure")
|
||||
|
||||
// Test the happy path.
|
||||
status = core.CertificateStatus{
|
||||
IsExpired: false,
|
||||
OCSPLastUpdated: time.Now(),
|
||||
OCSPResponse: respBytes,
|
||||
}
|
||||
src, err = NewDbSource(echoSelector{status: status}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create dbSource")
|
||||
_, err = src.Response(context.Background(), req)
|
||||
test.AssertNotError(t, err, "unexpected failure")
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/issuance"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
type responderID struct {
|
||||
nameHash []byte
|
||||
keyHash []byte
|
||||
}
|
||||
|
||||
type filterSource struct {
|
||||
wrapped Source
|
||||
hashAlgorithm crypto.Hash
|
||||
issuers map[issuance.IssuerNameID]responderID
|
||||
serialPrefixes []string
|
||||
counter *prometheus.CounterVec
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
// NewFilterSource returns a filterSource which performs various checks on the
|
||||
// OCSP requests sent to the wrapped Source, and the OCSP responses returned
|
||||
// by it.
|
||||
func NewFilterSource(issuerCerts []*issuance.Certificate, serialPrefixes []string, wrapped Source, stats prometheus.Registerer, log blog.Logger) (*filterSource, error) {
|
||||
if len(issuerCerts) < 1 {
|
||||
return nil, errors.New("Filter must include at least 1 issuer cert")
|
||||
}
|
||||
issuersByNameId := make(map[issuance.IssuerNameID]responderID)
|
||||
for _, issuerCert := range issuerCerts {
|
||||
keyHash := issuerCert.KeyHash()
|
||||
nameHash := issuerCert.NameHash()
|
||||
rid := responderID{
|
||||
keyHash: keyHash[:],
|
||||
nameHash: nameHash[:],
|
||||
}
|
||||
issuersByNameId[issuerCert.NameID()] = rid
|
||||
}
|
||||
counter := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ocsp_filter_responses",
|
||||
Help: "Count of OCSP requests/responses by action taken by the filter",
|
||||
}, []string{"result"})
|
||||
return &filterSource{
|
||||
wrapped: wrapped,
|
||||
hashAlgorithm: crypto.SHA1,
|
||||
issuers: issuersByNameId,
|
||||
serialPrefixes: serialPrefixes,
|
||||
counter: counter,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Response implements the Source interface. It checks the incoming request
|
||||
// to ensure that we want to handle it, fetches the response from the wrapped
|
||||
// Source, and checks that the response matches the request.
|
||||
func (src *filterSource) Response(ctx context.Context, req *ocsp.Request) (*Response, error) {
|
||||
iss, err := src.checkRequest(req)
|
||||
if err != nil {
|
||||
src.log.Debugf("Not responding to filtered OCSP request: %s", err.Error())
|
||||
src.counter.WithLabelValues("request_filtered").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := src.wrapped.Response(ctx, req)
|
||||
if err != nil {
|
||||
src.counter.WithLabelValues("wrapped_error").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = src.checkResponse(iss, resp)
|
||||
if err != nil {
|
||||
src.log.Warningf("OCSP Response not sent (issuer and serial mismatch) for CA=%s, Serial=%s", hex.EncodeToString(req.IssuerKeyHash), core.SerialToString(req.SerialNumber))
|
||||
src.counter.WithLabelValues("response_filtered").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src.counter.WithLabelValues("success").Inc()
|
||||
return resp, 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.
|
||||
// If the request passes all checks, then checkRequest returns the unique id of
|
||||
// the issuer cert specified in the request.
|
||||
func (src *filterSource) checkRequest(req *ocsp.Request) (issuance.IssuerNameID, error) {
|
||||
if req.HashAlgorithm != src.hashAlgorithm {
|
||||
return 0, fmt.Errorf("unsupported issuer key/name hash algorithm %s: %w", req.HashAlgorithm, ErrNotFound)
|
||||
}
|
||||
|
||||
if len(src.serialPrefixes) > 0 {
|
||||
serialString := core.SerialToString(req.SerialNumber)
|
||||
match := false
|
||||
for _, prefix := range src.serialPrefixes {
|
||||
if strings.HasPrefix(serialString, prefix) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
return 0, fmt.Errorf("unrecognized serial prefix: %w", ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
for nameID, rid := range src.issuers {
|
||||
if bytes.Equal(req.IssuerNameHash, rid.nameHash) && bytes.Equal(req.IssuerKeyHash, rid.keyHash) {
|
||||
return nameID, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unrecognized issuer key hash %s: %w", hex.EncodeToString(req.IssuerKeyHash), ErrNotFound)
|
||||
}
|
||||
|
||||
// checkResponse returns nil if the ocsp response was generated by the same
|
||||
// issuer as was identified in the request, or an error otherwise. 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 (src *filterSource) checkResponse(reqIssuerID issuance.IssuerNameID, resp *Response) error {
|
||||
respIssuerID := issuance.GetOCSPIssuerNameID(resp.Response)
|
||||
if reqIssuerID != respIssuerID {
|
||||
// This would be allowed if we used delegated responders, but we don't.
|
||||
return fmt.Errorf("responder name does not match requested issuer name")
|
||||
}
|
||||
|
||||
// In an ideal world, we'd also compare the Issuer Key Hash from the request's
|
||||
// CertID (equivalent to looking up the key hash in src.issuers) against the
|
||||
// Issuer Key Hash contained in the response's CertID. However, the Go OCSP
|
||||
// library does not provide access to the response's CertID, so we can't.
|
||||
// Specifically, we want to compare `src.issuers[reqIssuerID].keyHash` against
|
||||
// something like resp.CertID.IssuerKeyHash, but the latter does not exist.
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"encoding/hex"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/issuance"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/metrics"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
func TestNewFilter(t *testing.T) {
|
||||
_, err := NewFilterSource([]*issuance.Certificate{}, []string{}, nil, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertError(t, err, "didn't error when creating empty filter")
|
||||
|
||||
issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem")
|
||||
test.AssertNotError(t, err, "failed to load issuer cert")
|
||||
issuerNameId := issuer.NameID()
|
||||
|
||||
f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, nil, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "errored when creating good filter")
|
||||
test.AssertEquals(t, len(f.issuers), 1)
|
||||
test.AssertEquals(t, len(f.serialPrefixes), 1)
|
||||
test.AssertEquals(t, hex.EncodeToString(f.issuers[issuerNameId].keyHash), "fb784f12f96015832c9f177f3419b32e36ea4189")
|
||||
}
|
||||
|
||||
func TestCheckRequest(t *testing.T) {
|
||||
issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem")
|
||||
test.AssertNotError(t, err, "failed to load issuer cert")
|
||||
|
||||
f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, nil, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "errored when creating good filter")
|
||||
|
||||
reqBytes, err := ioutil.ReadFile("./testdata/ocsp.req")
|
||||
test.AssertNotError(t, err, "failed to read OCSP request")
|
||||
|
||||
// Select a bad hash algorithm.
|
||||
ocspReq, err := ocsp.ParseRequest(reqBytes)
|
||||
test.AssertNotError(t, err, "failed to prepare fake ocsp request")
|
||||
ocspReq.HashAlgorithm = crypto.MD5
|
||||
_, err = f.Response(context.Background(), ocspReq)
|
||||
test.AssertError(t, err, "accepted ocsp request with bad hash algorithm")
|
||||
|
||||
// Make the hash invalid.
|
||||
ocspReq, err = ocsp.ParseRequest(reqBytes)
|
||||
test.AssertNotError(t, err, "failed to prepare fake ocsp request")
|
||||
ocspReq.IssuerKeyHash[0]++
|
||||
_, err = f.Response(context.Background(), ocspReq)
|
||||
test.AssertError(t, err, "accepted ocsp request with bad issuer key hash")
|
||||
|
||||
// Make the serial prefix wrong by incrementing the first byte by 1.
|
||||
ocspReq, err = ocsp.ParseRequest(reqBytes)
|
||||
test.AssertNotError(t, err, "failed to prepare fake ocsp request")
|
||||
serialStr := []byte(core.SerialToString(ocspReq.SerialNumber))
|
||||
serialStr[0] = serialStr[0] + 1
|
||||
ocspReq.SerialNumber.SetString(string(serialStr), 16)
|
||||
_, err = f.Response(context.Background(), ocspReq)
|
||||
test.AssertError(t, err, "accepted ocsp request with bad serial prefix")
|
||||
}
|
||||
|
||||
type echoSource struct {
|
||||
resp *Response
|
||||
}
|
||||
|
||||
func (src *echoSource) Response(context.Context, *ocsp.Request) (*Response, error) {
|
||||
return src.resp, nil
|
||||
}
|
||||
|
||||
func TestCheckResponse(t *testing.T) {
|
||||
issuer, err := issuance.LoadCertificate("./testdata/test-ca.der.pem")
|
||||
test.AssertNotError(t, err, "failed to load issuer cert")
|
||||
|
||||
reqBytes, err := ioutil.ReadFile("./testdata/ocsp.req")
|
||||
test.AssertNotError(t, err, "failed to read OCSP request")
|
||||
req, err := ocsp.ParseRequest(reqBytes)
|
||||
test.AssertNotError(t, err, "failed to prepare fake ocsp request")
|
||||
|
||||
respBytes, err := ioutil.ReadFile("./testdata/ocsp.resp")
|
||||
test.AssertNotError(t, err, "failed to read OCSP response")
|
||||
resp, err := ocsp.ParseResponse(respBytes, nil)
|
||||
test.AssertNotError(t, err, "failed to parse OCSP response")
|
||||
|
||||
source := &echoSource{&Response{resp, respBytes}}
|
||||
f, err := NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, source, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "errored when creating good filter")
|
||||
|
||||
actual, err := f.Response(context.Background(), req)
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
test.AssertEquals(t, actual.Response, resp)
|
||||
|
||||
// Overwrite the Responder Name in the stored response to cause a diagreement.
|
||||
resp.RawResponderName = []byte("C = US, O = Foo, DN = Bar")
|
||||
source = &echoSource{&Response{resp, respBytes}}
|
||||
f, err = NewFilterSource([]*issuance.Certificate{issuer}, []string{"00"}, source, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "errored when creating good filter")
|
||||
|
||||
_, err = f.Response(context.Background(), req)
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
// inMemorySource wraps a map from serialNumber to Response and just looks up
|
||||
// Responses from that map with no safety checks. Useful for testing.
|
||||
type inMemorySource struct {
|
||||
responses map[string]*Response
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
// NewMemorySource returns an initialized InMemorySource which simply looks up
|
||||
// responses from an in-memory map based on the serial number in the request.
|
||||
func NewMemorySource(responses map[string]*Response, logger blog.Logger) (*inMemorySource, error) {
|
||||
return &inMemorySource{
|
||||
responses: responses,
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewMemorySourceFromFile reads the named file into an InMemorySource.
|
||||
// The file read by this function must contain whitespace-separated OCSP
|
||||
// responses. Each OCSP response must be in base64-encoded DER form (i.e.,
|
||||
// PEM without headers or whitespace). Invalid responses are ignored.
|
||||
// This function pulls the entire file into an InMemorySource.
|
||||
func NewMemorySourceFromFile(responseFile string, logger blog.Logger) (*inMemorySource, error) {
|
||||
fileContents, err := ioutil.ReadFile(responseFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responsesB64 := regexp.MustCompile(`\s`).Split(string(fileContents), -1)
|
||||
responses := make(map[string]*Response, len(responsesB64))
|
||||
for _, b64 := range responsesB64 {
|
||||
// if the line/space is empty just skip
|
||||
if b64 == "" {
|
||||
continue
|
||||
}
|
||||
der, tmpErr := base64.StdEncoding.DecodeString(b64)
|
||||
if tmpErr != nil {
|
||||
logger.Errf("Base64 decode error %s on: %s", tmpErr, b64)
|
||||
continue
|
||||
}
|
||||
|
||||
response, tmpErr := ocsp.ParseResponse(der, nil)
|
||||
if tmpErr != nil {
|
||||
logger.Errf("OCSP decode error %s on: %s", tmpErr, b64)
|
||||
continue
|
||||
}
|
||||
|
||||
responses[response.SerialNumber.String()] = &Response{
|
||||
Response: response,
|
||||
Raw: der,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Read %d OCSP responses", len(responses))
|
||||
return NewMemorySource(responses, logger)
|
||||
}
|
||||
|
||||
// Response looks up an OCSP response to provide for a given request.
|
||||
// InMemorySource looks up a response purely based on serial number,
|
||||
// without regard to what issuer the request is asking for.
|
||||
func (src inMemorySource) Response(_ context.Context, request *ocsp.Request) (*Response, error) {
|
||||
response, present := src.responses[request.SerialNumber.String()]
|
||||
if !present {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return response, nil
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
type multiSource struct {
|
||||
primary Source
|
||||
secondary Source
|
||||
counter *prometheus.CounterVec
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
func NewMultiSource(primary, secondary Source, stats prometheus.Registerer, log blog.Logger) (*multiSource, error) {
|
||||
if primary == nil || secondary == nil {
|
||||
return nil, errors.New("must provide both primary and secondary sources")
|
||||
}
|
||||
counter := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ocsp_multiplex_responses",
|
||||
Help: "Count of OCSP requests/responses by action taken by the multiSource",
|
||||
}, []string{"result"})
|
||||
return &multiSource{
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
counter: counter,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Response implements the Source interface. It performs lookups using both the
|
||||
// primary and secondary wrapped Sources. It returns whichever response arrives
|
||||
// first, with the caveat that if the secondary Source responds quicker, it will
|
||||
// wait for the result from the primary to ensure that they agree.
|
||||
func (src *multiSource) Response(ctx context.Context, req *ocsp.Request) (*Response, error) {
|
||||
serialString := core.SerialToString(req.SerialNumber)
|
||||
|
||||
primaryChan := getResponse(ctx, src.primary, req)
|
||||
secondaryChan := getResponse(ctx, src.secondary, req)
|
||||
|
||||
// If the primary source returns first, check the output and return
|
||||
// it. If the secondary source wins, then wait for the primary so the
|
||||
// results from the secondary can be verified. It is important that we
|
||||
// never return a response from the secondary source that is good if the
|
||||
// primary has a revoked status. If the secondary source wins the race and
|
||||
// passes these checks, return its response instead.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
src.counter.WithLabelValues("timed_out").Inc()
|
||||
return nil, fmt.Errorf("looking up OCSP response for serial: %s err: %w", serialString, ctx.Err())
|
||||
|
||||
case primaryResult := <-primaryChan:
|
||||
src.counter.WithLabelValues("primary_result").Inc()
|
||||
return primaryResult.resp, primaryResult.err
|
||||
|
||||
case secondaryResult := <-secondaryChan:
|
||||
// If secondary returns first, wait for primary to return for
|
||||
// comparison.
|
||||
var primaryResult responseResult
|
||||
|
||||
// Listen for cancellation or timeout waiting for primary result.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
src.counter.WithLabelValues("timed_out").Inc()
|
||||
return nil, fmt.Errorf("looking up OCSP response for serial: %s err: %w", serialString, ctx.Err())
|
||||
|
||||
case primaryResult = <-primaryChan:
|
||||
}
|
||||
|
||||
// Check for error returned from the primary lookup, return on error.
|
||||
if primaryResult.err != nil {
|
||||
src.counter.WithLabelValues("primary_error").Inc()
|
||||
return nil, primaryResult.err
|
||||
}
|
||||
|
||||
// Check for error returned from the secondary lookup. If error return
|
||||
// primary lookup result.
|
||||
if secondaryResult.err != nil {
|
||||
src.counter.WithLabelValues("secondary_error").Inc()
|
||||
return primaryResult.resp, nil
|
||||
}
|
||||
|
||||
// If the secondary response status doesn't match primary, return
|
||||
// primary response.
|
||||
if secondaryResult.resp.Status != primaryResult.resp.Status {
|
||||
src.counter.WithLabelValues("mismatch").Inc()
|
||||
return primaryResult.resp, nil
|
||||
}
|
||||
|
||||
// The secondary response has passed checks, return it.
|
||||
src.counter.WithLabelValues("secondary_result").Inc()
|
||||
return secondaryResult.resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
type responseResult struct {
|
||||
resp *Response
|
||||
err error
|
||||
}
|
||||
|
||||
// getResponse provides a thin wrapper around an underlying Source's Response
|
||||
// method, calling it in a goroutine and passing the result back on a channel.
|
||||
func getResponse(ctx context.Context, src Source, req *ocsp.Request) chan responseResult {
|
||||
responseChan := make(chan responseResult)
|
||||
|
||||
go func() {
|
||||
defer close(responseChan)
|
||||
|
||||
resp, err := src.Response(ctx, req)
|
||||
responseChan <- responseResult{resp, err}
|
||||
}()
|
||||
|
||||
return responseChan
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/metrics"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
type succeedSource struct {
|
||||
resp *Response
|
||||
}
|
||||
|
||||
func (src *succeedSource) Response(context.Context, *ocsp.Request) (*Response, error) {
|
||||
if src.resp != nil {
|
||||
return src.resp, nil
|
||||
}
|
||||
// We can't just return nil, as the multiSource checks the Statuses from each
|
||||
// Source to ensure they agree.
|
||||
return &Response{&ocsp.Response{Status: ocsp.Good}, []byte{}}, nil
|
||||
}
|
||||
|
||||
type failSource struct{}
|
||||
|
||||
func (src *failSource) Response(context.Context, *ocsp.Request) (*Response, error) {
|
||||
return nil, errors.New("failure")
|
||||
}
|
||||
|
||||
func TestBothGood(t *testing.T) {
|
||||
src, err := NewMultiSource(&succeedSource{}, &succeedSource{}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
_, err = src.Response(context.Background(), &ocsp.Request{})
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
}
|
||||
|
||||
func TestPrimaryGoodSecondaryErr(t *testing.T) {
|
||||
src, err := NewMultiSource(&succeedSource{}, &failSource{}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
_, err = src.Response(context.Background(), &ocsp.Request{})
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
}
|
||||
|
||||
func TestPrimaryErrSecondaryGood(t *testing.T) {
|
||||
src, err := NewMultiSource(&failSource{}, &succeedSource{}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
_, err = src.Response(context.Background(), &ocsp.Request{})
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
||||
|
||||
func TestBothErr(t *testing.T) {
|
||||
src, err := NewMultiSource(&failSource{}, &failSource{}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
_, err = src.Response(context.Background(), &ocsp.Request{})
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
||||
|
||||
func TestBothSucceedButDisagree(t *testing.T) {
|
||||
otherResp := &Response{&ocsp.Response{Status: ocsp.Revoked}, []byte{}}
|
||||
src, err := NewMultiSource(&succeedSource{otherResp}, &succeedSource{}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
resp, err := src.Response(context.Background(), &ocsp.Request{})
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
test.AssertEquals(t, resp.Status, ocsp.Revoked)
|
||||
}
|
||||
|
||||
// blockingSource doesn't return until its channel is closed.
|
||||
// Use `defer close(signal)` to cause it to block until the test is done.
|
||||
type blockingSource struct {
|
||||
signal chan struct{}
|
||||
}
|
||||
|
||||
func (src *blockingSource) Response(context.Context, *ocsp.Request) (*Response, error) {
|
||||
<-src.signal
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestPrimaryGoodSecondaryTimeout(t *testing.T) {
|
||||
signal := make(chan struct{})
|
||||
defer close(signal)
|
||||
|
||||
src, err := NewMultiSource(&succeedSource{}, &blockingSource{signal}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
_, err = src.Response(context.Background(), &ocsp.Request{})
|
||||
test.AssertNotError(t, err, "unexpected error")
|
||||
}
|
||||
|
||||
func TestPrimaryTimeoutSecondaryGood(t *testing.T) {
|
||||
signal := make(chan struct{})
|
||||
defer close(signal)
|
||||
|
||||
src, err := NewMultiSource(&blockingSource{signal}, &succeedSource{}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
// We use cancellation instead of timeout so we don't have to wait on real time.
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
_, err = src.Response(ctx, &ocsp.Request{})
|
||||
errChan <- err
|
||||
}()
|
||||
cancel()
|
||||
err = <-errChan
|
||||
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
||||
|
||||
func TestBothTimeout(t *testing.T) {
|
||||
signal := make(chan struct{})
|
||||
defer close(signal)
|
||||
|
||||
src, err := NewMultiSource(&blockingSource{signal}, &blockingSource{signal}, metrics.NoopRegisterer, blog.NewMock())
|
||||
test.AssertNotError(t, err, "failed to create multiSource")
|
||||
|
||||
// We use cancellation instead of timeout so we don't have to wait on real time.
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
_, err = src.Response(ctx, &ocsp.Request{})
|
||||
errChan <- err
|
||||
}()
|
||||
cancel()
|
||||
err = <-errChan
|
||||
|
||||
test.AssertError(t, err, "expected error")
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/rocsp"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
type redisSource struct {
|
||||
client *rocsp.Client
|
||||
counter *prometheus.CounterVec
|
||||
// Note: this logger is not currently used, as all audit log events are from
|
||||
// the dbSource right now, but it should and will be used in the future.
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
// NewRedisSource returns a dbSource which will look up OCSP responses in a
|
||||
// Redis table.
|
||||
func NewRedisSource(client *rocsp.Client, stats prometheus.Registerer, log blog.Logger) (*redisSource, error) {
|
||||
counter := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ocsp_redis_responses",
|
||||
Help: "Count of OCSP requests/responses by action taken by the redisSource",
|
||||
}, []string{"result"})
|
||||
return &redisSource{
|
||||
client: client,
|
||||
counter: counter,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Response implements the Source interface. It looks up the requested OCSP
|
||||
// response in the redis cluster.
|
||||
func (src *redisSource) Response(ctx context.Context, req *ocsp.Request) (*Response, error) {
|
||||
serialString := core.SerialToString(req.SerialNumber)
|
||||
|
||||
respBytes, err := src.client.GetResponse(ctx, serialString)
|
||||
if err != nil {
|
||||
src.counter.WithLabelValues("lookup_error").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := ocsp.ParseResponse(respBytes, nil)
|
||||
if err != nil {
|
||||
src.counter.WithLabelValues("parse_error").Inc()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src.counter.WithLabelValues("success").Inc()
|
||||
return &Response{Response: resp, Raw: respBytes}, nil
|
||||
}
|
|
@ -27,8 +27,9 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
// Package ocsp implements an OCSP responder based on a generic storage backend.
|
||||
package ocsp
|
||||
// Package responder implements an OCSP HTTP responder based on a generic
|
||||
// storage backend.
|
||||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -41,7 +42,6 @@ import (
|
|||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/honeycombio/beeline-go"
|
||||
|
@ -57,83 +57,6 @@ import (
|
|||
// indicate that the responder should reply with unauthorizedErrorResponse.
|
||||
var ErrNotFound = errors.New("Request OCSP Response not found")
|
||||
|
||||
// Source represents the logical source of OCSP responses, i.e.,
|
||||
// the logic that actually chooses a response based on a request. In
|
||||
// order to create an actual responder, wrap one of these in a Responder
|
||||
// object and pass it to http.Handle. By default the Responder will set
|
||||
// the headers Cache-Control to "max-age=(response.NextUpdate-now), public, no-transform, must-revalidate",
|
||||
// Last-Modified to response.ThisUpdate, Expires to response.NextUpdate,
|
||||
// ETag to the SHA256 hash of the response, and Content-Type to
|
||||
// application/ocsp-response. If you want to override these headers,
|
||||
// or set extra headers, your source should return a http.Header
|
||||
// with the headers you wish to set. If you don't want to set any
|
||||
// extra headers you may return nil instead.
|
||||
type Source interface {
|
||||
Response(context.Context, *ocsp.Request) ([]byte, http.Header, error)
|
||||
}
|
||||
|
||||
// An InMemorySource is a map from serialNumber -> der(response)
|
||||
type InMemorySource struct {
|
||||
responses map[string][]byte
|
||||
log blog.Logger
|
||||
}
|
||||
|
||||
// NewMemorySource returns an initialized InMemorySource
|
||||
func NewMemorySource(responses map[string][]byte, logger blog.Logger) Source {
|
||||
return InMemorySource{
|
||||
responses: responses,
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Response looks up an OCSP response to provide for a given request.
|
||||
// InMemorySource looks up a response purely based on serial number,
|
||||
// without regard to what issuer the request is asking for.
|
||||
func (src InMemorySource) Response(_ context.Context, request *ocsp.Request) ([]byte, http.Header, error) {
|
||||
response, present := src.responses[request.SerialNumber.String()]
|
||||
if !present {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return response, nil, nil
|
||||
}
|
||||
|
||||
// NewMemorySourceFromFile reads the named file into an InMemorySource.
|
||||
// The file read by this function must contain whitespace-separated OCSP
|
||||
// responses. Each OCSP response must be in base64-encoded DER form (i.e.,
|
||||
// PEM without headers or whitespace). Invalid responses are ignored.
|
||||
// This function pulls the entire file into an InMemorySource.
|
||||
func NewMemorySourceFromFile(responseFile string, logger blog.Logger) (Source, error) {
|
||||
fileContents, err := ioutil.ReadFile(responseFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responsesB64 := regexp.MustCompile(`\s`).Split(string(fileContents), -1)
|
||||
responses := make(map[string][]byte, len(responsesB64))
|
||||
for _, b64 := range responsesB64 {
|
||||
// if the line/space is empty just skip
|
||||
if b64 == "" {
|
||||
continue
|
||||
}
|
||||
der, tmpErr := base64.StdEncoding.DecodeString(b64)
|
||||
if tmpErr != nil {
|
||||
logger.Errf("Base64 decode error %s on: %s", tmpErr, b64)
|
||||
continue
|
||||
}
|
||||
|
||||
response, tmpErr := ocsp.ParseResponse(der, nil)
|
||||
if tmpErr != nil {
|
||||
logger.Errf("OCSP decode error %s on: %s", tmpErr, b64)
|
||||
continue
|
||||
}
|
||||
|
||||
responses[response.SerialNumber.String()] = der
|
||||
}
|
||||
|
||||
logger.Infof("Read %d OCSP responses", len(responses))
|
||||
return NewMemorySource(responses, logger), nil
|
||||
}
|
||||
|
||||
var responseTypeToString = map[ocsp.ResponseStatus]string{
|
||||
ocsp.Success: "Success",
|
||||
ocsp.Malformed: "Malformed",
|
||||
|
@ -143,10 +66,10 @@ var responseTypeToString = map[ocsp.ResponseStatus]string{
|
|||
ocsp.Unauthorized: "Unauthorized",
|
||||
}
|
||||
|
||||
// A Responder object provides the HTTP logic to expose a
|
||||
// Source of OCSP responses.
|
||||
// A Responder object provides an HTTP wrapper around a Source.
|
||||
type Responder struct {
|
||||
Source Source
|
||||
timeout time.Duration
|
||||
responseTypes *prometheus.CounterVec
|
||||
responseAges prometheus.Histogram
|
||||
requestSizes prometheus.Histogram
|
||||
|
@ -155,7 +78,7 @@ type Responder struct {
|
|||
}
|
||||
|
||||
// NewResponder instantiates a Responder with the give Source.
|
||||
func NewResponder(source Source, stats prometheus.Registerer, logger blog.Logger) *Responder {
|
||||
func NewResponder(source Source, timeout time.Duration, stats prometheus.Registerer, logger blog.Logger) *Responder {
|
||||
requestSizes := prometheus.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "ocsp_request_sizes",
|
||||
|
@ -188,6 +111,7 @@ func NewResponder(source Source, stats prometheus.Registerer, logger blog.Logger
|
|||
|
||||
return &Responder{
|
||||
Source: source,
|
||||
timeout: timeout,
|
||||
responseTypes: responseTypes,
|
||||
responseAges: responseAges,
|
||||
requestSizes: requestSizes,
|
||||
|
@ -196,19 +120,6 @@ func NewResponder(source Source, stats prometheus.Registerer, logger blog.Logger
|
|||
}
|
||||
}
|
||||
|
||||
func overrideHeaders(response http.ResponseWriter, headers http.Header) {
|
||||
for k, v := range headers {
|
||||
if len(v) == 1 {
|
||||
response.Header().Set(k, v[0])
|
||||
} else if len(v) > 1 {
|
||||
response.Header().Del(k)
|
||||
for _, e := range v {
|
||||
response.Header().Add(k, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type logEvent struct {
|
||||
IP string `json:"ip,omitempty"`
|
||||
UA string `json:"ua,omitempty"`
|
||||
|
@ -234,10 +145,16 @@ var hashToString = map[crypto.Hash]string{
|
|||
crypto.SHA512: "SHA512",
|
||||
}
|
||||
|
||||
// A Responder can process both GET and POST requests. The mapping
|
||||
// from an OCSP request to an OCSP response is done by the Source;
|
||||
// the Responder simply decodes the request, and passes back whatever
|
||||
// response is provided by the source.
|
||||
// A Responder can process both GET and POST requests. The mapping from an OCSP
|
||||
// request to an OCSP response is done by the Source; the Responder simply
|
||||
// decodes the request, and passes back whatever response is provided by the
|
||||
// source.
|
||||
// The Responder will set these headers:
|
||||
// Cache-Control: "max-age=(response.NextUpdate-now), public, no-transform, must-revalidate",
|
||||
// Last-Modified: response.ThisUpdate,
|
||||
// Expires: response.NextUpdate,
|
||||
// ETag: the SHA256 hash of the response, and
|
||||
// Content-Type: application/ocsp-response.
|
||||
// Note: The caller must use http.StripPrefix to strip any path components
|
||||
// (including '/') on GET requests.
|
||||
// Do not use this responder in conjunction with http.NewServeMux, because the
|
||||
|
@ -246,6 +163,13 @@ var hashToString = map[crypto.Hash]string{
|
|||
// encoding.
|
||||
func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
ctx := request.Context()
|
||||
|
||||
if rs.timeout != 0 {
|
||||
var cancel func()
|
||||
ctx, cancel = context.WithTimeout(ctx, rs.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
le := logEvent{
|
||||
IP: request.RemoteAddr,
|
||||
UA: request.UserAgent(),
|
||||
|
@ -356,7 +280,7 @@ func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Reques
|
|||
beeline.AddFieldToTrace(ctx, "ocsp.hash_alg", hashToString[ocspRequest.HashAlgorithm])
|
||||
|
||||
// Look up OCSP response from source
|
||||
ocspResponse, headers, err := rs.Source.Response(ctx, ocspRequest)
|
||||
ocspResponse, err := rs.Source.Response(ctx, ocspRequest)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
rs.log.Infof("No response found for request: serial %x, request body %s",
|
||||
|
@ -373,23 +297,13 @@ func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
parsedResponse, err := ocsp.ParseResponse(ocspResponse, nil)
|
||||
if err != nil {
|
||||
rs.log.Errf("Error parsing response for serial %x: %x %s",
|
||||
ocspRequest.SerialNumber, parsedResponse, err)
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
response.Write(ocsp.InternalErrorErrorResponse)
|
||||
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.InternalError]}).Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// Write OCSP response
|
||||
response.Header().Add("Last-Modified", parsedResponse.ThisUpdate.Format(time.RFC1123))
|
||||
response.Header().Add("Expires", parsedResponse.NextUpdate.Format(time.RFC1123))
|
||||
response.Header().Add("Last-Modified", ocspResponse.ThisUpdate.Format(time.RFC1123))
|
||||
response.Header().Add("Expires", ocspResponse.NextUpdate.Format(time.RFC1123))
|
||||
now := rs.clk.Now()
|
||||
maxAge := 0
|
||||
if now.Before(parsedResponse.NextUpdate) {
|
||||
maxAge = int(parsedResponse.NextUpdate.Sub(now) / time.Second)
|
||||
if now.Before(ocspResponse.NextUpdate) {
|
||||
maxAge = int(ocspResponse.NextUpdate.Sub(now) / time.Second)
|
||||
} else {
|
||||
// TODO(#530): we want max-age=0 but this is technically an authorized OCSP response
|
||||
// (despite being stale) and 5019 forbids attaching no-cache
|
||||
|
@ -402,11 +316,15 @@ func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Reques
|
|||
maxAge,
|
||||
),
|
||||
)
|
||||
responseHash := sha256.Sum256(ocspResponse)
|
||||
responseHash := sha256.Sum256(ocspResponse.Raw)
|
||||
response.Header().Add("ETag", fmt.Sprintf("\"%X\"", responseHash))
|
||||
|
||||
if headers != nil {
|
||||
overrideHeaders(response, headers)
|
||||
serialString := core.SerialToString(ocspResponse.SerialNumber)
|
||||
if len(serialString) > 2 {
|
||||
// Set a cache tag that is equal to the last two bytes of the serial.
|
||||
// We expect that to be randomly distributed, so each tag should map to
|
||||
// about 1/256 of our responses.
|
||||
response.Header().Add("Edge-Cache-Tag", serialString[len(serialString)-2:])
|
||||
}
|
||||
|
||||
// RFC 7232 says that a 304 response must contain the above
|
||||
|
@ -419,7 +337,7 @@ func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Reques
|
|||
}
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
response.Write(ocspResponse)
|
||||
rs.responseAges.Observe(rs.clk.Now().Sub(parsedResponse.ThisUpdate).Seconds())
|
||||
response.Write(ocspResponse.Raw)
|
||||
rs.responseAges.Observe(rs.clk.Now().Sub(ocspResponse.ThisUpdate).Seconds())
|
||||
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Success]}).Inc()
|
||||
}
|
|
@ -27,7 +27,7 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
package ocsp
|
||||
package responder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
@ -37,14 +37,13 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmhodges/clock"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
goocsp "golang.org/x/crypto/ocsp"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
|
@ -59,12 +58,16 @@ const (
|
|||
|
||||
type testSource struct{}
|
||||
|
||||
func (ts testSource) Response(_ context.Context, r *goocsp.Request) ([]byte, http.Header, error) {
|
||||
resp, err := hex.DecodeString("3082031D0A0100A08203163082031206092B060105050730010104820303308202FF3081E8A1453043310B300906035504061302555331123010060355040A1309676F6F6420677579733120301E06035504031317434120696E7465726D6564696174652028525341292041180F32303230303631393030333730305A30818D30818A304C300906052B0E03021A0500041417779CF67D84CD4449A2FC7EAC431F9823D8575A04149F2970E80CF9C75ECC1F2871D8C390CD19F40108021300FF8B2AEC5293C6B31D0BC0BA329CF594E7BAA116180F32303230303631393030333733305AA0030A0101180F32303230303631393030303030305AA011180F32303230303632333030303030305A300D06092A864886F70D01010B0500038202010011688303203098FC522D2C599A234B136930E3C4680F2F3192188B98D6EE90E8479449968C51335FADD1636584ACEA9D01A30790BD90190FA35A47E793718128B19E9ED156382C1B68245A6887F547B0B86C44C2354B8DBA94D8BFCAA768EB55FA84AEB4026DBEFC687DB280D21C0B3497A11909804A20F402BDD95E4843C02E30435C2570FFC4EB152FE2785B8D268AC996619644AEC9CF50959D46DEB21DFE96B4D2881D61ABBCA9B6BFEC2DB9132801CAE737C862F0AEAB4948B63F35740CE93FCDBC148F5070790D7BBA1A87E15078CD8335F83686142CE8AC3AD21FAE45B87A7B12562D9F245352A83E3901E97E5EC77E9817990712D8BE60860ABA58804DDE4ECDCA6AEFD3D8764FDBABF0AB1902FA9A7C4C3F5814C25C5E78E0754469E087CAED81E50A5873CADFCAC42963AB38CFD11096BE4201DE4589B57EC48B3DA05A65800D654160E022F6748CD93B431A17270C1B27E313734FCF85F22547D060F23F594BD68C6330C2705190A04905FBD2389E2DD21C0188809E03D713F56BF95953C9897DA6D4D074D70F164270C41BFB386B69E86EB3B9192FEA8F43CE5368CC9AF8687DEE567672A8580BA6A9F76E6E6705DD2F76F48C2C180C763CF4C48AF78C25D40EA7278CB2FBC78958B3179301825B420A7CAE7ACE4C41B5BA7D567AABC9C2701EE75A28F9181E044EDAAA55A31538AA9C526D4C324B9AE58D2922")
|
||||
func (ts testSource) Response(_ context.Context, r *ocsp.Request) (*Response, error) {
|
||||
respBytes, err := hex.DecodeString("3082031D0A0100A08203163082031206092B060105050730010104820303308202FF3081E8A1453043310B300906035504061302555331123010060355040A1309676F6F6420677579733120301E06035504031317434120696E7465726D6564696174652028525341292041180F32303230303631393030333730305A30818D30818A304C300906052B0E03021A0500041417779CF67D84CD4449A2FC7EAC431F9823D8575A04149F2970E80CF9C75ECC1F2871D8C390CD19F40108021300FF8B2AEC5293C6B31D0BC0BA329CF594E7BAA116180F32303230303631393030333733305AA0030A0101180F32303230303631393030303030305AA011180F32303230303632333030303030305A300D06092A864886F70D01010B0500038202010011688303203098FC522D2C599A234B136930E3C4680F2F3192188B98D6EE90E8479449968C51335FADD1636584ACEA9D01A30790BD90190FA35A47E793718128B19E9ED156382C1B68245A6887F547B0B86C44C2354B8DBA94D8BFCAA768EB55FA84AEB4026DBEFC687DB280D21C0B3497A11909804A20F402BDD95E4843C02E30435C2570FFC4EB152FE2785B8D268AC996619644AEC9CF50959D46DEB21DFE96B4D2881D61ABBCA9B6BFEC2DB9132801CAE737C862F0AEAB4948B63F35740CE93FCDBC148F5070790D7BBA1A87E15078CD8335F83686142CE8AC3AD21FAE45B87A7B12562D9F245352A83E3901E97E5EC77E9817990712D8BE60860ABA58804DDE4ECDCA6AEFD3D8764FDBABF0AB1902FA9A7C4C3F5814C25C5E78E0754469E087CAED81E50A5873CADFCAC42963AB38CFD11096BE4201DE4589B57EC48B3DA05A65800D654160E022F6748CD93B431A17270C1B27E313734FCF85F22547D060F23F594BD68C6330C2705190A04905FBD2389E2DD21C0188809E03D713F56BF95953C9897DA6D4D074D70F164270C41BFB386B69E86EB3B9192FEA8F43CE5368CC9AF8687DEE567672A8580BA6A9F76E6E6705DD2F76F48C2C180C763CF4C48AF78C25D40EA7278CB2FBC78958B3179301825B420A7CAE7ACE4C41B5BA7D567AABC9C2701EE75A28F9181E044EDAAA55A31538AA9C526D4C324B9AE58D2922")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil, nil
|
||||
resp, err := ocsp.ParseResponse(respBytes, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Response{resp, respBytes}, nil
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
|
@ -164,55 +167,6 @@ func TestRequestTooBig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
var testResp = `308204f90a0100a08204f2308204ee06092b0601050507300101048204df308204db3081a7a003020100a121301f311d301b06035504030c146861707079206861636b65722066616b65204341180f32303135303932333231303630305a306c306a3042300906052b0e03021a0500041439e45eb0e3a861c7fa3a3973876be61f7b7d98860414fb784f12f96015832c9f177f3419b32e36ea41890209009cf1912ea8d509088000180f32303135303932333030303030305aa011180f32303330303832363030303030305a300d06092a864886f70d01010b05000382010100c17ed5f12c408d214092c86cb2d6ba9881637a9d5cafb8ddc05aed85806a554c37abdd83c2e00a4bb25b2d0dda1e1c0be65144377471bca53f14616f379ee0c0b436c697b400b7eba9513c5be6d92fbc817586d568156293cfa0099d64585146def907dee36eb650c424a00207b01813aa7ae90e65045339482eeef12b6fa8656315da8f8bb1375caa29ac3858f891adb85066c35b5176e154726ae746016e42e0d6016668ff10a8aa9637417d29be387a1bdba9268b13558034ab5f3e498a47fb096f2e1b39236b22956545884fbbed1884f1bc9686b834d8def4802bac8f79924a36867af87412f808977abaf6457f3cda9e7eccbd0731bcd04865b899ee41a08203193082031530820311308201f9a0030201020209009cf1912ea8d50908300d06092a864886f70d01010b0500301f311d301b06035504030c146861707079206861636b65722066616b65204341301e170d3135303430373233353033385a170d3235303430343233353033385a301f311d301b06035504030c146861707079206861636b65722066616b6520434130820122300d06092a864886f70d01010105000382010f003082010a0282010100c20a47799a05c512b27717633413d770f936bf99de62f130c8774d476deac0029aa6c9d1bb519605df32d34b336394d48e9adc9bbeb48652767dafdb5241c2fc54ce9650e33cb672298888c403642407270cc2f46667f07696d3dd62cfd1f41a8dc0ed60d7c18366b1d2cd462d34a35e148e8695a9a3ec62b656bd129a211a9a534847992d005b0412bcdffdde23085eeca2c32c2693029b5a79f1090fe0b1cb4a154b5c36bc04c7d5a08fa2a58700d3c88d5059205bc5560dc9480f1732b1ad29b030ed3235f7fb868f904fdc79f98ffb5c4e7d4b831ce195f171729ec3f81294df54e66bd3f83d81843b640aea5d7ec64d0905a9dbb03e6ff0e6ac523d36ab0203010001a350304e301d0603551d0e04160414fb784f12f96015832c9f177f3419b32e36ea4189301f0603551d23041830168014fb784f12f96015832c9f177f3419b32e36ea4189300c0603551d13040530030101ff300d06092a864886f70d01010b050003820101001df436be66ff938ccbfb353026962aa758763a777531119377845109e7c2105476c165565d5bbce1464b41bd1d392b079a7341c978af754ca9b3bd7976d485cbbe1d2070d2d4feec1e0f79e8fec9df741e0ea05a26a658d3866825cc1aa2a96a0a04942b2c203cc39501f917a899161dfc461717fe9301fce6ea1afffd7b7998f8941cf76f62def994c028bd1c4b49b17c4d243a6fb058c484968cf80501234da89347108b56b2640cb408e3c336fd72cd355c7f690a15405a7f4ba1e30a6be4a51d262b586f77f8472b207fdd194efab8d3a2683cc148abda7a11b9de1db9307b8ed5a9cd20226f668bd6ac5a3852fd449e42899b7bc915ee747891a110a971`
|
||||
|
||||
type testHeaderSource struct {
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
func (ts testHeaderSource) Response(_ context.Context, r *goocsp.Request) ([]byte, http.Header, error) {
|
||||
resp, _ := hex.DecodeString(testResp)
|
||||
return resp, ts.headers, nil
|
||||
}
|
||||
|
||||
func TestOverrideHeaders(t *testing.T) {
|
||||
headers := http.Header(map[string][]string{
|
||||
"Content-Type": {"yup"},
|
||||
"Cache-Control": {"nope"},
|
||||
"New": {"header"},
|
||||
"Expires": {"0"},
|
||||
"Last-Modified": {"now"},
|
||||
"Etag": {"mhm"},
|
||||
})
|
||||
responder := Responder{
|
||||
Source: testHeaderSource{headers: headers},
|
||||
responseTypes: prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "ocspResponses-test",
|
||||
},
|
||||
[]string{"type"},
|
||||
),
|
||||
responseAges: prometheus.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "ocspAges-test",
|
||||
Buckets: []float64{43200},
|
||||
},
|
||||
),
|
||||
clk: clock.NewFake(),
|
||||
log: blog.NewMock(),
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
responder.ServeHTTP(rw, &http.Request{
|
||||
Method: "GET",
|
||||
URL: &url.URL{Path: "MFQwUjBQME4wTDAJBgUrDgMCGgUABBQ55F6w46hhx%2Fo6OXOHa%2BYfe32YhgQU%2B3hPEvlgFYMsnxd%2FNBmzLjbqQYkCEwD6Wh0MaVKu9gJ3By9DI%2F%2Fxsd4%3D"},
|
||||
})
|
||||
|
||||
if !reflect.DeepEqual(rw.Header(), headers) {
|
||||
t.Fatalf("Unexpected Headers returned: wanted %s, got %s", headers, rw.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheHeaders(t *testing.T) {
|
||||
source, err := NewMemorySourceFromFile(responseFile, blog.NewMock())
|
||||
if err != nil {
|
|
@ -0,0 +1,20 @@
|
|||
package responder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/crypto/ocsp"
|
||||
)
|
||||
|
||||
// Response is a wrapper around the standard library's *ocsp.Response, but it
|
||||
// also carries with it the raw bytes of the encoded response.
|
||||
type Response struct {
|
||||
*ocsp.Response
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
// Source represents the logical source of OCSP responses, i.e.,
|
||||
// the logic that actually chooses a response based on a request.
|
||||
type Source interface {
|
||||
Response(context.Context, *ocsp.Request) (*Response, error)
|
||||
}
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,19 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDETCCAfmgAwIBAgIJAJzxkS6o1QkIMA0GCSqGSIb3DQEBCwUAMB8xHTAbBgNV
|
||||
BAMMFGhhcHB5IGhhY2tlciBmYWtlIENBMB4XDTE1MDQwNzIzNTAzOFoXDTI1MDQw
|
||||
NDIzNTAzOFowHzEdMBsGA1UEAwwUaGFwcHkgaGFja2VyIGZha2UgQ0EwggEiMA0G
|
||||
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCCkd5mgXFErJ3F2M0E9dw+Ta/md5i
|
||||
8TDId01HberAApqmydG7UZYF3zLTSzNjlNSOmtybvrSGUnZ9r9tSQcL8VM6WUOM8
|
||||
tnIpiIjEA2QkBycMwvRmZ/B2ltPdYs/R9BqNwO1g18GDZrHSzUYtNKNeFI6Glamj
|
||||
7GK2Vr0SmiEamlNIR5ktAFsEErzf/d4jCF7sosMsJpMCm1p58QkP4LHLShVLXDa8
|
||||
BMfVoI+ipYcA08iNUFkgW8VWDclIDxcysa0psDDtMjX3+4aPkE/cefmP+1xOfUuD
|
||||
HOGV8XFynsP4EpTfVOZr0/g9gYQ7ZArqXX7GTQkFqduwPm/w5qxSPTarAgMBAAGj
|
||||
UDBOMB0GA1UdDgQWBBT7eE8S+WAVgyyfF380GbMuNupBiTAfBgNVHSMEGDAWgBT7
|
||||
eE8S+WAVgyyfF380GbMuNupBiTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A4IBAQAd9Da+Zv+TjMv7NTAmliqnWHY6d3UxEZN3hFEJ58IQVHbBZVZdW7zhRktB
|
||||
vR05Kweac0HJeK91TKmzvXl21IXLvh0gcNLU/uweD3no/snfdB4OoFompljThmgl
|
||||
zBqiqWoKBJQrLCA8w5UB+ReomRYd/EYXF/6TAfzm6hr//Xt5mPiUHPdvYt75lMAo
|
||||
vRxLSbF8TSQ6b7BYxISWjPgFASNNqJNHEItWsmQMtAjjwzb9cs01XH9pChVAWn9L
|
||||
oeMKa+SlHSYrWG93+EcrIH/dGU76uNOiaDzBSKvaehG53h25MHuO1anNICJvZovW
|
||||
rFo4Uv1EnkKJm3vJFe50eJGhEKlx
|
||||
-----END CERTIFICATE-----
|
|
@ -1,4 +1,4 @@
|
|||
package ocsp_updater
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -13,9 +13,9 @@ import (
|
|||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
capb "github.com/letsencrypt/boulder/ca/proto"
|
||||
"github.com/letsencrypt/boulder/cmd"
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
ocsp_updater_config "github.com/letsencrypt/boulder/ocsp_updater/config"
|
||||
rocsp_config "github.com/letsencrypt/boulder/rocsp/config"
|
||||
"github.com/letsencrypt/boulder/sa"
|
||||
)
|
||||
|
@ -63,6 +63,37 @@ func (c *failCounter) Value() int {
|
|||
return c.count
|
||||
}
|
||||
|
||||
// Config provides the various window tick times and batch sizes needed
|
||||
// for the OCSP updater
|
||||
type Config struct {
|
||||
cmd.ServiceConfig
|
||||
DB cmd.DBConfig
|
||||
ReadOnlyDB cmd.DBConfig
|
||||
Redis *rocsp_config.RedisConfig
|
||||
|
||||
// Issuers is a map from filenames to short issuer IDs.
|
||||
// Each filename must contain an issuer certificate. The short issuer
|
||||
// IDs are arbitrarily assigned and must be consistent across OCSP
|
||||
// components. For production we'll use the number part of the CN, i.e.
|
||||
// E1 -> 1, R3 -> 3, etc.
|
||||
Issuers map[string]int
|
||||
|
||||
OldOCSPWindow cmd.ConfigDuration
|
||||
OldOCSPBatchSize int
|
||||
|
||||
OCSPMinTimeToExpiry cmd.ConfigDuration
|
||||
ParallelGenerateOCSPRequests int
|
||||
|
||||
SignFailureBackoffFactor float64
|
||||
SignFailureBackoffMax cmd.ConfigDuration
|
||||
|
||||
SerialSuffixShards string
|
||||
|
||||
OCSPGeneratorService *cmd.GRPCClientConfig
|
||||
|
||||
Features map[string]bool
|
||||
}
|
||||
|
||||
// OCSPUpdater contains the useful objects for the Updater
|
||||
type OCSPUpdater struct {
|
||||
log blog.Logger
|
||||
|
@ -113,7 +144,10 @@ func New(
|
|||
issuers []rocsp_config.ShortIDIssuer,
|
||||
serialSuffixes []string,
|
||||
ogc capb.OCSPGeneratorClient,
|
||||
config ocsp_updater_config.Config,
|
||||
// A temporary evil. This constructor should not take a JSON config as input;
|
||||
// everything should be prepped ahead of time.
|
||||
// TODO(XXX): Fix this, or file a bug to fix it later.
|
||||
config Config,
|
||||
log blog.Logger,
|
||||
) (*OCSPUpdater, error) {
|
||||
if config.OldOCSPBatchSize == 0 {
|
|
@ -1,4 +1,4 @@
|
|||
package ocsp_updater
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -21,7 +21,6 @@ import (
|
|||
bgrpc "github.com/letsencrypt/boulder/grpc"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/metrics"
|
||||
ocsp_updater_config "github.com/letsencrypt/boulder/ocsp_updater/config"
|
||||
rocsp_config "github.com/letsencrypt/boulder/rocsp/config"
|
||||
"github.com/letsencrypt/boulder/sa"
|
||||
sapb "github.com/letsencrypt/boulder/sa/proto"
|
||||
|
@ -76,7 +75,7 @@ func setup(t *testing.T) (*OCSPUpdater, sapb.StorageAuthorityClient, *db.Wrapped
|
|||
nil,
|
||||
strings.Fields("0 1 2 3 4 5 6 7 8 9 a b c d e f"),
|
||||
&mockOCSP{},
|
||||
ocsp_updater_config.Config{
|
||||
Config{
|
||||
OldOCSPBatchSize: 1,
|
||||
OldOCSPWindow: cmd.ConfigDuration{Duration: time.Second},
|
||||
SignFailureBackoffFactor: 1.5,
|
||||
|
@ -228,7 +227,7 @@ func TestROCSP(t *testing.T) {
|
|||
updater.rocspClient = recorder
|
||||
updater.issuers, err = rocsp_config.LoadIssuers(
|
||||
map[string]int{
|
||||
"../test/hierarchy/int-e1.cert.pem": 23,
|
||||
"../../test/hierarchy/int-e1.cert.pem": 23,
|
||||
},
|
||||
)
|
||||
test.AssertNotError(t, err, "loading issuers")
|
||||
|
@ -717,7 +716,7 @@ func mkNewUpdaterWithStrings(t *testing.T, shards []string) (*OCSPUpdater, error
|
|||
nil,
|
||||
shards,
|
||||
&mockOCSP{},
|
||||
ocsp_updater_config.Config{
|
||||
Config{
|
||||
OldOCSPBatchSize: 1,
|
||||
OldOCSPWindow: cmd.ConfigDuration{Duration: time.Second},
|
||||
SignFailureBackoffFactor: 1.5,
|
|
@ -1,37 +0,0 @@
|
|||
package ocsp_updater_config
|
||||
|
||||
import (
|
||||
"github.com/letsencrypt/boulder/cmd"
|
||||
rocsp_config "github.com/letsencrypt/boulder/rocsp/config"
|
||||
)
|
||||
|
||||
// Config provides the various window tick times and batch sizes needed
|
||||
// for the OCSP updater
|
||||
type Config struct {
|
||||
cmd.ServiceConfig
|
||||
DB cmd.DBConfig
|
||||
ReadOnlyDB cmd.DBConfig
|
||||
Redis *rocsp_config.RedisConfig
|
||||
|
||||
// Issuers is a map from filenames to short issuer IDs.
|
||||
// Each filename must contain an issuer certificate. The short issuer
|
||||
// IDs are arbitrarily assigned and must be consistent across OCSP
|
||||
// components. For production we'll use the number part of the CN, i.e.
|
||||
// E1 -> 1, R3 -> 3, etc.
|
||||
Issuers map[string]int
|
||||
|
||||
OldOCSPWindow cmd.ConfigDuration
|
||||
OldOCSPBatchSize int
|
||||
|
||||
OCSPMinTimeToExpiry cmd.ConfigDuration
|
||||
ParallelGenerateOCSPRequests int
|
||||
|
||||
SignFailureBackoffFactor float64
|
||||
SignFailureBackoffMax cmd.ConfigDuration
|
||||
|
||||
SerialSuffixShards string
|
||||
|
||||
OCSPGeneratorService *cmd.GRPCClientConfig
|
||||
|
||||
Features map[string]bool
|
||||
}
|
Loading…
Reference in New Issue