...

Source file src/github.com/letsencrypt/boulder/ocsp/responder/redis/checked_redis_source.go

Documentation: github.com/letsencrypt/boulder/ocsp/responder/redis

     1  package redis
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"reflect"
     7  	"sync"
     8  
     9  	"github.com/prometheus/client_golang/prometheus"
    10  	"golang.org/x/crypto/ocsp"
    11  
    12  	"github.com/letsencrypt/boulder/core"
    13  	"github.com/letsencrypt/boulder/db"
    14  	berrors "github.com/letsencrypt/boulder/errors"
    15  	blog "github.com/letsencrypt/boulder/log"
    16  	"github.com/letsencrypt/boulder/ocsp/responder"
    17  	"github.com/letsencrypt/boulder/sa"
    18  	sapb "github.com/letsencrypt/boulder/sa/proto"
    19  )
    20  
    21  // dbSelector is a limited subset of the db.WrappedMap interface to allow for
    22  // easier mocking of mysql operations in tests.
    23  type dbSelector interface {
    24  	SelectOne(ctx context.Context, holder interface{}, query string, args ...interface{}) error
    25  }
    26  
    27  // rocspSourceInterface expands on responder.Source by adding a private signAndSave method.
    28  // This allows checkedRedisSource to trigger a live signing if the DB disagrees with Redis.
    29  type rocspSourceInterface interface {
    30  	Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error)
    31  	signAndSave(ctx context.Context, req *ocsp.Request, cause signAndSaveCause) (*responder.Response, error)
    32  }
    33  
    34  // checkedRedisSource implements the Source interface. It relies on two
    35  // underlying datastores to provide its OCSP responses: a rocspSourceInterface
    36  // (a Source that can also signAndSave new responses) to provide the responses
    37  // themselves, and the database to double-check that those responses match the
    38  // authoritative revocation status stored in the db.
    39  // TODO(#6285): Inline the rocspSourceInterface into this type.
    40  // TODO(#6295): Remove the dbMap after all deployments use the SA instead.
    41  type checkedRedisSource struct {
    42  	base    rocspSourceInterface
    43  	dbMap   dbSelector
    44  	sac     sapb.StorageAuthorityReadOnlyClient
    45  	counter *prometheus.CounterVec
    46  	log     blog.Logger
    47  }
    48  
    49  // NewCheckedRedisSource builds a source that queries both the DB and Redis, and confirms
    50  // the value in Redis matches the DB.
    51  func NewCheckedRedisSource(base *redisSource, dbMap dbSelector, sac sapb.StorageAuthorityReadOnlyClient, stats prometheus.Registerer, log blog.Logger) (*checkedRedisSource, error) {
    52  	if base == nil {
    53  		return nil, errors.New("base was nil")
    54  	}
    55  
    56  	// We have to use reflect here because these arguments are interfaces, and
    57  	// thus checking for nil the normal way doesn't work reliably, because they
    58  	// may be non-nil interfaces whose inner value is still nil, i.e. "boxed nil".
    59  	// But using reflect here is okay, because we only expect this constructor to
    60  	// be called once per process.
    61  	if (reflect.TypeOf(sac) == nil || reflect.ValueOf(sac).IsNil()) &&
    62  		(reflect.TypeOf(dbMap) == nil || reflect.ValueOf(dbMap).IsNil()) {
    63  		return nil, errors.New("either SA gRPC or direct DB connection must be provided")
    64  	}
    65  
    66  	return newCheckedRedisSource(base, dbMap, sac, stats, log), nil
    67  }
    68  
    69  // newCheckRedisSource is an internal-only constructor that takes a private interface as a parameter.
    70  // We call this from tests and from NewCheckedRedisSource.
    71  func newCheckedRedisSource(base rocspSourceInterface, dbMap dbSelector, sac sapb.StorageAuthorityReadOnlyClient, stats prometheus.Registerer, log blog.Logger) *checkedRedisSource {
    72  	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
    73  		Name: "checked_rocsp_responses",
    74  		Help: "Count of OCSP requests/responses from checkedRedisSource, by result",
    75  	}, []string{"result"})
    76  	stats.MustRegister(counter)
    77  
    78  	return &checkedRedisSource{
    79  		base:    base,
    80  		dbMap:   dbMap,
    81  		sac:     sac,
    82  		counter: counter,
    83  		log:     log,
    84  	}
    85  }
    86  
    87  // Response implements the responder.Source interface. It looks up the requested OCSP
    88  // response in the redis cluster and looks up the corresponding status in the DB. If
    89  // the status disagrees with what redis says, it signs a fresh response and serves it.
    90  func (src *checkedRedisSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) {
    91  	serialString := core.SerialToString(req.SerialNumber)
    92  
    93  	var wg sync.WaitGroup
    94  	wg.Add(2)
    95  	var dbStatus *sapb.RevocationStatus
    96  	var redisResult *responder.Response
    97  	var redisErr, dbErr error
    98  	go func() {
    99  		defer wg.Done()
   100  		if src.sac != nil {
   101  			dbStatus, dbErr = src.sac.GetRevocationStatus(ctx, &sapb.Serial{Serial: serialString})
   102  		} else {
   103  			dbStatus, dbErr = sa.SelectRevocationStatus(ctx, src.dbMap, serialString)
   104  		}
   105  	}()
   106  	go func() {
   107  		defer wg.Done()
   108  		redisResult, redisErr = src.base.Response(ctx, req)
   109  	}()
   110  	wg.Wait()
   111  
   112  	if dbErr != nil {
   113  		// If the DB says "not found", the certificate either doesn't exist or has
   114  		// expired and been removed from the DB. We don't need to check the Redis error.
   115  		if db.IsNoRows(dbErr) || errors.Is(dbErr, berrors.NotFound) {
   116  			src.counter.WithLabelValues("not_found").Inc()
   117  			return nil, responder.ErrNotFound
   118  		}
   119  
   120  		src.counter.WithLabelValues("db_error").Inc()
   121  		return nil, dbErr
   122  	}
   123  
   124  	if redisErr != nil {
   125  		src.counter.WithLabelValues("redis_error").Inc()
   126  		return nil, redisErr
   127  	}
   128  
   129  	// If the DB status matches the status returned from the Redis pipeline, all is good.
   130  	if agree(dbStatus, redisResult.Response) {
   131  		src.counter.WithLabelValues("success").Inc()
   132  		return redisResult, nil
   133  	}
   134  
   135  	// Otherwise, the DB is authoritative. Trigger a fresh signing.
   136  	freshResult, err := src.base.signAndSave(ctx, req, causeMismatch)
   137  	if err != nil {
   138  		src.counter.WithLabelValues("revocation_re_sign_error").Inc()
   139  		return nil, err
   140  	}
   141  
   142  	if agree(dbStatus, freshResult.Response) {
   143  		src.counter.WithLabelValues("revocation_re_sign_success").Inc()
   144  		return freshResult, nil
   145  	}
   146  
   147  	// This could happen for instance with replication lag, or if the
   148  	// RA was talking to a different DB.
   149  	src.counter.WithLabelValues("revocation_re_sign_mismatch").Inc()
   150  	return nil, errors.New("freshly signed status did not match DB")
   151  
   152  }
   153  
   154  // agree returns true if the contents of the redisResult ocsp.Response agree with what's in the DB.
   155  func agree(dbStatus *sapb.RevocationStatus, redisResult *ocsp.Response) bool {
   156  	return dbStatus.Status == int64(redisResult.Status) &&
   157  		dbStatus.RevokedReason == int64(redisResult.RevocationReason) &&
   158  		dbStatus.RevokedDate.AsTime().Equal(redisResult.RevokedAt)
   159  }
   160  

View as plain text