ocsp/responder: update Redis source to use live signing (#6207)

This enables ocsp-responder to talk to the RA and request freshly signed
OCSP responses.

ocsp/responder/redis_source is moved to ocsp/responder/redis/redis_source.go
and significantly modified. Instead of assuming a response is always available
in Redis, it wraps a live-signing source. When a response is not available,
it attempts a live signing.

If live signing succeeds, the Redis responder returns the result right away
and attempts to write a copy to Redis on a goroutine using a background
context.

To make things more efficient, I eliminate an unneeded ocsp.ParseResponse
from the storage path. And I factored out a FakeResponse helper to make
the unittests more manageable.

Commits should be reviewable one-by-one.

Fixes #6191
This commit is contained in:
Jacob Hoffman-Andrews 2022-07-18 10:47:14 -07:00 committed by GitHub
parent fa2f6174ad
commit 29724cb0b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 553 additions and 127 deletions

View File

@ -17,10 +17,14 @@ import (
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc"
"github.com/letsencrypt/boulder/issuance"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics/measured_http"
"github.com/letsencrypt/boulder/ocsp/responder"
"github.com/letsencrypt/boulder/ocsp/responder/live"
redis_responder "github.com/letsencrypt/boulder/ocsp/responder/redis"
rapb "github.com/letsencrypt/boulder/ra/proto"
rocsp_config "github.com/letsencrypt/boulder/rocsp/config"
"github.com/letsencrypt/boulder/sa"
)
@ -69,13 +73,30 @@ type Config struct {
// This has a default value of 61h.
ExpectedFreshness cmd.ConfigDuration
// A limit on how many requests to the RA (and onwards to the CA) will
// be made to sign responses that are not fresh in the cache. This
// should be set to somewhat less than
// (HSM signing capacity) / (number of ocsp-responders).
// Requests that would exceed this limit will block until capacity is
// available and eventually 500.
MaxInflightSignings int
ShutdownStopTimeout cmd.ConfigDuration
RequiredSerialPrefixes []string
Features map[string]bool
// Configuration for using Redis as a cache. This configuration should
// allow for both read and write access.
Redis rocsp_config.RedisConfig
// TLS client certificate, private key, and trusted root bundle.
TLS cmd.TLSConfig
// How to communicate with the RA when it is necessary to generate a
// fresh OCSP response.
RAService *cmd.GRPCClientConfig
}
Syslog cmd.SyslogConfig
@ -136,22 +157,37 @@ as generated by Boulder's ceremony command.
source, err = responder.NewDbSource(dbMap, stats, logger)
cmd.FailOnError(err, "Could not create database source")
// 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 {
rocspReader, err := rocsp_config.MakeReadClient(&c.OCSPResponder.Redis, clk, stats)
// Set up the redis source and the combined multiplex source if there
// is a config for it and the feature flag is enabled. Otherwise
// just pass through the existing mysql source.
if c.OCSPResponder.Redis.Addrs != nil && features.Enabled(features.ROCSPStage1) {
rocspReader, err := rocsp_config.MakeClient(&c.OCSPResponder.Redis, clk, stats)
cmd.FailOnError(err, "Could not make redis client")
err = rocspReader.Ping(context.Background())
cmd.FailOnError(err, "pinging Redis")
rocspSource, err := responder.NewRedisSource(rocspReader, stats, logger)
cmd.FailOnError(err, "Could not create redis source")
expectedFreshness := c.OCSPResponder.ExpectedFreshness.Duration
if expectedFreshness == 0 {
expectedFreshness = 61 * time.Hour
}
tlsConfig, err := c.OCSPResponder.TLS.Load()
cmd.FailOnError(err, "TLS config")
clientMetrics := bgrpc.NewClientMetrics(stats)
raConn, err := bgrpc.ClientSetup(c.OCSPResponder.RAService, tlsConfig, clientMetrics, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA")
rac := rapb.NewRegistrationAuthorityClient(raConn)
maxInflight := c.OCSPResponder.MaxInflightSignings
if maxInflight == 0 {
maxInflight = 1000
}
liveSource := live.New(rac, int64(maxInflight))
rocspSource, err := redis_responder.NewRedisSource(rocspReader, liveSource, expectedFreshness, clk, stats, logger)
cmd.FailOnError(err, "Could not create redis source")
source, err = responder.NewMultiSource(source, rocspSource, expectedFreshness, stats, logger)
cmd.FailOnError(err, "Could not create multiplex source")
}

View File

@ -218,8 +218,13 @@ func (cl *client) signAndStoreResponses(ctx context.Context, input <-chan *sa.Ce
output <- processResult{id: uint64(status.ID), err: err}
continue
}
resp, err := ocsp.ParseResponse(result.Response, nil)
if err != nil {
output <- processResult{id: uint64(status.ID), err: err}
continue
}
err = cl.redis.StoreResponse(ctx, result.Response)
err = cl.redis.StoreResponse(ctx, resp)
if err != nil {
output <- processResult{id: uint64(status.ID), err: err}
} else {
@ -272,7 +277,7 @@ func (cl *client) storeResponse(ctx context.Context, respBytes []byte) error {
time.Until(resp.NextUpdate).Hours(),
)
err = cl.redis.StoreResponse(ctx, respBytes)
err = cl.redis.StoreResponse(ctx, resp)
if err != nil {
return fmt.Errorf("storing response: %w", err)
}

View File

@ -37,11 +37,13 @@ func _() {
_ = x[SHA1CSRs-26]
_ = x[AllowUnrecognizedFeatures-27]
_ = x[RejectDuplicateCSRExtensions-28]
_ = x[ROCSPStage1-29]
_ = x[ROCSPStage2-30]
}
const _FeatureFlag_name = "unusedPrecertificateRevocationStripDefaultSchemePortNonCFSSLSignerStoreIssuerInfoStreamlineOrderAndAuthzsV1DisableNewValidationsExpirationMailerDontLookTwiceCAAValidationMethodsCAAAccountURIEnforceMultiVAMultiVAFullResultsMandatoryPOSTAsGETAllowV1RegistrationStoreRevokerInfoRestrictRSAKeySizesFasterNewOrdersRateLimitECDSAForAllServeRenewalInfoGetAuthzReadOnlyGetAuthzUseIndexCheckFailedAuthorizationsFirstAllowReRevocationMozRevocationReasonsOldTLSOutboundOldTLSInboundSHA1CSRsAllowUnrecognizedFeaturesRejectDuplicateCSRExtensions"
const _FeatureFlag_name = "unusedPrecertificateRevocationStripDefaultSchemePortNonCFSSLSignerStoreIssuerInfoStreamlineOrderAndAuthzsV1DisableNewValidationsExpirationMailerDontLookTwiceCAAValidationMethodsCAAAccountURIEnforceMultiVAMultiVAFullResultsMandatoryPOSTAsGETAllowV1RegistrationStoreRevokerInfoRestrictRSAKeySizesFasterNewOrdersRateLimitECDSAForAllServeRenewalInfoGetAuthzReadOnlyGetAuthzUseIndexCheckFailedAuthorizationsFirstAllowReRevocationMozRevocationReasonsOldTLSOutboundOldTLSInboundSHA1CSRsAllowUnrecognizedFeaturesRejectDuplicateCSRExtensionsROCSPStage1ROCSPStage2"
var _FeatureFlag_index = [...]uint16{0, 6, 30, 52, 66, 81, 105, 128, 157, 177, 190, 204, 222, 240, 259, 275, 294, 318, 329, 345, 361, 377, 407, 424, 444, 458, 471, 479, 504, 532}
var _FeatureFlag_index = [...]uint16{0, 6, 30, 52, 66, 81, 105, 128, 157, 177, 190, 204, 222, 240, 259, 275, 294, 318, 329, 345, 361, 377, 407, 424, 444, 458, 471, 479, 504, 532, 543, 554}
func (i FeatureFlag) String() string {
if i < 0 || i >= FeatureFlag(len(_FeatureFlag_index)-1) {

View File

@ -97,6 +97,14 @@ const (
// not contain duplicate extensions. This behavior will be on by default in
// go1.19.
RejectDuplicateCSRExtensions
// ROCSPStage1 enables querying Redis, live-signing response, and storing
// to Redis, but doesn't serve responses from Redis.
ROCSPStage1
// ROCSPStage2 enables querying Redis, live-signing a response, and storing
// to Redis, and does serve responses from Redis when appropriate (when
// they are fresh, and agree with MariaDB's status for the certificate).
ROCSPStage2
)
// List of features and their default value, protected by fMu
@ -130,6 +138,8 @@ var features = map[FeatureFlag]bool{
AllowUnrecognizedFeatures: false,
ExpirationMailerDontLookTwice: false,
RejectDuplicateCSRExtensions: false,
ROCSPStage1: false,
ROCSPStage2: false,
}
var fMu = new(sync.RWMutex)

View File

@ -2,17 +2,13 @@ package live
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"testing"
capb "github.com/letsencrypt/boulder/ca/proto"
"github.com/letsencrypt/boulder/core"
ocsp_test "github.com/letsencrypt/boulder/ocsp/test"
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/test"
"golang.org/x/crypto/ocsp"
@ -35,42 +31,16 @@ func (m mockOCSPGenerator) GenerateOCSP(ctx context.Context, in *rapb.GenerateOC
}
func TestLiveResponse(t *testing.T) {
// Make a fake CA to sign OCSP with
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1337),
BasicConstraintsValid: true,
IsCA: true,
Subject: pkix.Name{CommonName: "test CA"},
}
issuerBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
issuer, err := x509.ParseCertificate(issuerBytes)
if err != nil {
t.Fatal(err)
}
eeSerial := big.NewInt(1)
respBytes, err := ocsp.CreateResponse(issuer, issuer, ocsp.Response{
fakeResp, _, _ := ocsp_test.FakeResponse(ocsp.Response{
SerialNumber: eeSerial,
}, key)
if err != nil {
t.Fatal(err)
}
source := New(mockOCSPGenerator{respBytes}, 1)
})
source := New(mockOCSPGenerator{fakeResp.Raw}, 1)
resp, err := source.Response(context.Background(), &ocsp.Request{
SerialNumber: eeSerial,
})
test.AssertNotError(t, err, "getting response")
test.AssertByteEquals(t, resp.Raw, respBytes)
test.AssertByteEquals(t, resp.Raw, fakeResp.Raw)
expectedSerial := "000000000000000000000000000000000001"
if core.SerialToString(resp.SerialNumber) != expectedSerial {
t.Errorf("expected serial %s, got %s", expectedSerial, resp.SerialNumber)

View File

@ -5,6 +5,7 @@ import (
"errors"
"time"
"github.com/letsencrypt/boulder/features"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/rocsp"
"github.com/prometheus/client_golang/prometheus"
@ -126,16 +127,14 @@ func (src *multiSource) Response(ctx context.Context, req *ocsp.Request) (*Respo
return primaryResponse, nil
}
// If the primary response is fresher than the secondary, return the
// primary response. If ocsp-updater is updating Redis, this shouldn't
// happen (since revocation cases are caught above).
if primaryResponse.ThisUpdate.After(secondaryResponse.ThisUpdate) {
src.counter.WithLabelValues("primary_newer").Inc()
return primaryResponse, nil
// ROCSP Stage 2 enables serving responses from Redis
if features.Enabled(features.ROCSPStage2) {
src.counter.WithLabelValues("secondary").Inc()
return secondaryResponse, nil
}
src.counter.WithLabelValues("primary_stale_secondary_success").Inc()
return secondaryResponse, nil
src.counter.WithLabelValues("primary").Inc()
return primaryResponse, nil
}
// checkSecondary updates the src.counter metrics when we're planning to return

View File

@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/letsencrypt/boulder/features"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
"github.com/letsencrypt/boulder/test"
@ -151,6 +152,11 @@ func TestSecondaryTimeout(t *testing.T) {
}
func TestPrimaryStale(t *testing.T) {
err := features.Set(map[string]bool{
"ROCSPStage2": true,
})
test.AssertNotError(t, err, "setting features")
src, err := NewMultiSource(stale{}, ok{}, expectedFreshness, metrics.NoopRegisterer, blog.NewMock())
test.AssertNotError(t, err, "failed to create multiSource")

View File

@ -0,0 +1,109 @@
package responder
import (
"context"
"errors"
"time"
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/core"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/ocsp/responder"
"github.com/letsencrypt/boulder/rocsp"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/ocsp"
)
type rocspClient interface {
GetResponse(ctx context.Context, serial string) ([]byte, error)
StoreResponse(ctx context.Context, resp *ocsp.Response) error
}
type redisSource struct {
client rocspClient
signer responder.Source
counter *prometheus.CounterVec
clk clock.Clock
staleThreshold time.Duration
// 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 responder.Source which will look up OCSP responses in a
// Redis table.
func NewRedisSource(
client *rocsp.WritingClient,
signer responder.Source,
staleThreshold time.Duration,
clk clock.Clock,
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"})
stats.MustRegister(counter)
var rocspReader rocspClient
if client != nil {
rocspReader = client
}
return &redisSource{
client: rocspReader,
signer: signer,
counter: counter,
staleThreshold: staleThreshold,
clk: clk,
log: log,
}, nil
}
// Response implements the responder.Source interface. It looks up the requested OCSP
// response in the redis cluster.
func (src *redisSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) {
serialString := core.SerialToString(req.SerialNumber)
respBytes, err := src.client.GetResponse(ctx, serialString)
if err != nil {
if errors.Is(err, rocsp.ErrRedisNotFound) {
return src.signAndSave(ctx, req, "not_found_redis")
}
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
}
if src.isStale(resp) {
src.counter.WithLabelValues("stale").Inc()
return src.signAndSave(ctx, req, "stale_redis")
}
src.counter.WithLabelValues("success").Inc()
return &responder.Response{Response: resp, Raw: respBytes}, nil
}
func (src *redisSource) isStale(resp *ocsp.Response) bool {
return src.clk.Since(resp.ThisUpdate) > src.staleThreshold
}
func (src *redisSource) signAndSave(ctx context.Context, req *ocsp.Request, cause string) (*responder.Response, error) {
resp, err := src.signer.Response(ctx, req)
if err != nil {
if errors.Is(err, rocsp.ErrRedisNotFound) {
src.counter.WithLabelValues(cause + "_certificate_not_found").Inc()
return nil, responder.ErrNotFound
}
src.counter.WithLabelValues(cause + "_signing_error").Inc()
return nil, err
}
src.counter.WithLabelValues(cause + "_signing_success").Inc()
go src.client.StoreResponse(context.Background(), resp.Response)
return resp, nil
}

View File

@ -0,0 +1,229 @@
package responder
import (
"context"
"errors"
"math/big"
"testing"
"time"
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
"github.com/letsencrypt/boulder/ocsp/responder"
ocsp_test "github.com/letsencrypt/boulder/ocsp/test"
"github.com/letsencrypt/boulder/rocsp"
"github.com/letsencrypt/boulder/test"
"golang.org/x/crypto/ocsp"
)
// notFoundRedis is a mock *rocsp.WritingClient that (a) returns "not found"
// for all GetResponse, and (b) sends all StoreResponse serial numbers to
// a channel. The latter is necessary because the code under test calls
// StoreResponse from a goroutine, so we need something to synchronize back to
// the testing goroutine.
// For tests where you do not expect StoreResponse to be called, set the chan
// to nil so sends will panic.
type notFoundRedis struct {
serialStored chan *big.Int
}
func (nfr *notFoundRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) {
return nil, rocsp.ErrRedisNotFound
}
func (nfr *notFoundRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error {
nfr.serialStored <- resp.SerialNumber
return nil
}
type recordingSigner struct {
serialRequested *big.Int
}
func (rs *recordingSigner) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) {
if rs.serialRequested != nil {
panic("signed twice")
}
rs.serialRequested = req.SerialNumber
// Return a fake response with only serial number filled, because that's
// all the test cares about.
return &responder.Response{Response: &ocsp.Response{
SerialNumber: req.SerialNumber,
}}, nil
}
func TestNotFound(t *testing.T) {
recordingSigner := recordingSigner{}
src, err := NewRedisSource(nil, &recordingSigner, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock())
test.AssertNotError(t, err, "making source")
notFoundRedis := &notFoundRedis{make(chan *big.Int)}
src.client = notFoundRedis
serial := big.NewInt(987654321)
_, err = src.Response(context.Background(), &ocsp.Request{
SerialNumber: serial,
})
test.AssertNotError(t, err, "signing response when not found")
if recordingSigner.serialRequested.Cmp(serial) != 0 {
t.Errorf("issued signing request for serial %x; expected %x", recordingSigner.serialRequested, serial)
}
stored := <-notFoundRedis.serialStored
if stored == nil {
t.Fatalf("response was never stored")
}
if stored.Cmp(serial) != 0 {
t.Errorf("stored response for serial %x; expected %x", notFoundRedis.serialStored, serial)
}
}
type panicSource struct{}
func (ps panicSource) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) {
panic("shouldn't happen")
}
type errorRedis struct{}
func (er errorRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) {
return nil, errors.New("the enzabulators florbled")
}
func (er errorRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error {
panic("shouldn't happen")
}
func TestQueryError(t *testing.T) {
src, err := NewRedisSource(nil, panicSource{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock())
test.AssertNotError(t, err, "making source")
src.client = errorRedis{}
_, err = src.Response(context.Background(), &ocsp.Request{
SerialNumber: big.NewInt(314159),
})
test.AssertError(t, err, "expected error when Redis errored")
}
type garbleRedis struct{}
func (er garbleRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) {
return []byte("not a valid OCSP response, I can tell by the pixels"), nil
}
func (er garbleRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error {
panic("shouldn't happen")
}
func TestParseError(t *testing.T) {
src, err := NewRedisSource(nil, panicSource{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock())
test.AssertNotError(t, err, "making source")
src.client = garbleRedis{}
_, err = src.Response(context.Background(), &ocsp.Request{
SerialNumber: big.NewInt(314159),
})
test.AssertError(t, err, "expected error when Redis returned junk")
if errors.Is(err, rocsp.ErrRedisNotFound) {
t.Errorf("incorrect error value ErrRedisNotFound; expected general error")
}
}
type errorSigner struct{}
func (es errorSigner) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) {
return nil, errors.New("cannot sign; lost my pen")
}
func TestSignError(t *testing.T) {
src, err := NewRedisSource(nil, errorSigner{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock())
test.AssertNotError(t, err, "making source")
src.client = &notFoundRedis{nil}
_, err = src.Response(context.Background(), &ocsp.Request{
SerialNumber: big.NewInt(2718),
})
test.AssertError(t, err, "Expected error when signer errored")
}
// staleRedis is a mock *rocsp.WritingClient that (a) returns response with a
// fixed ThisUpdate for all GetResponse, and (b) sends all StoreResponse serial
// numbers to a channel. The latter is necessary because the code under test
// calls StoreResponse from a goroutine, so we need something to synchronize
// back to the testing goroutine.
type staleRedis struct {
serialStored chan *big.Int
thisUpdate time.Time
}
func (sr *staleRedis) GetResponse(ctx context.Context, serial string) ([]byte, error) {
serInt, err := core.StringToSerial(serial)
if err != nil {
return nil, err
}
resp, _, err := ocsp_test.FakeResponse(ocsp.Response{
SerialNumber: serInt,
ThisUpdate: sr.thisUpdate,
})
if err != nil {
return nil, err
}
return resp.Raw, nil
}
func (sr *staleRedis) StoreResponse(ctx context.Context, resp *ocsp.Response) error {
sr.serialStored <- resp.SerialNumber
return nil
}
func TestStale(t *testing.T) {
recordingSigner := recordingSigner{}
clk := clock.NewFake()
src, err := NewRedisSource(nil, &recordingSigner, time.Second, clk, metrics.NoopRegisterer, log.NewMock())
test.AssertNotError(t, err, "making source")
staleRedis := &staleRedis{
serialStored: make(chan *big.Int),
thisUpdate: clk.Now().Add(-time.Hour),
}
src.client = staleRedis
serial := big.NewInt(8675309)
_, err = src.Response(context.Background(), &ocsp.Request{
SerialNumber: serial,
})
test.AssertNotError(t, err, "signing response when not found")
if recordingSigner.serialRequested == nil {
t.Fatalf("signing source was never called")
}
if recordingSigner.serialRequested.Cmp(serial) != 0 {
t.Errorf("issued signing request for serial %x; expected %x", recordingSigner.serialRequested, serial)
}
stored := <-staleRedis.serialStored
if stored == nil {
t.Fatalf("response was never stored")
}
if stored.Cmp(serial) != 0 {
t.Errorf("stored response for serial %x; expected %x", staleRedis.serialStored, serial)
}
}
// notFoundSigner is a Source that always returns NotFound.
type notFoundSigner struct{}
func (nfs notFoundSigner) Response(ctx context.Context, req *ocsp.Request) (*responder.Response, error) {
return nil, responder.ErrNotFound
}
func TestCertificateNotFound(t *testing.T) {
src, err := NewRedisSource(nil, notFoundSigner{}, time.Second, clock.NewFake(), metrics.NoopRegisterer, log.NewMock())
test.AssertNotError(t, err, "making source")
notFoundRedis := &notFoundRedis{nil}
src.client = notFoundRedis
_, err = src.Response(context.Background(), &ocsp.Request{
SerialNumber: big.NewInt(777777777),
})
if !errors.Is(err, responder.ErrNotFound) {
t.Errorf("expected NotFound error, got %s", err)
}
}

View File

@ -1,56 +0,0 @@
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"})
stats.MustRegister(counter)
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
}

48
ocsp/test/response.go Normal file
View File

@ -0,0 +1,48 @@
package ocsp_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"golang.org/x/crypto/ocsp"
)
// FakeResponse signs and then parses an OCSP response, using fields from the input
// template. To do so, it generates a new signing key and makes an issuer certificate.
func FakeResponse(template ocsp.Response) (*ocsp.Response, *x509.Certificate, error) {
// Make a fake CA to sign OCSP with
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1337),
BasicConstraintsValid: true,
IsCA: true,
Subject: pkix.Name{CommonName: "test CA"},
}
issuerBytes, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
if err != nil {
return nil, nil, err
}
issuer, err := x509.ParseCertificate(issuerBytes)
if err != nil {
return nil, nil, err
}
respBytes, err := ocsp.CreateResponse(issuer, issuer, template, key)
if err != nil {
return nil, nil, err
}
response, err := ocsp.ParseResponse(respBytes, issuer)
if err != nil {
return nil, nil, err
}
return response, issuer, nil
}

View File

@ -3,6 +3,8 @@ package rocsp
import (
"context"
"fmt"
"golang.org/x/crypto/ocsp"
)
// MockWriteClient is a mock
@ -12,7 +14,7 @@ type MockWriteClient struct {
// StoreResponse mocks a rocsp.StoreResponse method and returns nil or an
// error depending on the desired state.
func (r MockWriteClient) StoreResponse(ctx context.Context, respBytes []byte) error {
func (r MockWriteClient) StoreResponse(ctx context.Context, resp *ocsp.Response) error {
return r.StoreReponseReturnError
}

View File

@ -84,24 +84,19 @@ func NewWritingClient(rdb *redis.ClusterClient, timeout time.Duration, clk clock
}
// StoreResponse parses the given bytes as an OCSP response, and stores it
// into Redis. Returns error if the OCSP response fails to parse. The
// expiration time (ttl) of the Redis key is set to OCSP response `NextUpdate`.
func (c *WritingClient) StoreResponse(ctx context.Context, respBytes []byte) error {
// into Redis. The expiration time (ttl) of the Redis key is set to OCSP
// response `NextUpdate`.
func (c *WritingClient) StoreResponse(ctx context.Context, resp *ocsp.Response) error {
start := c.clk.Now()
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
resp, err := ocsp.ParseResponse(respBytes, nil)
if err != nil {
return fmt.Errorf("parsing %d-byte response: %w", len(respBytes), err)
}
serial := core.SerialToString(resp.SerialNumber)
// Set the ttl duration to the response `NextUpdate - now()`
ttl := time.Until(resp.NextUpdate)
err = c.rdb.Set(ctx, serial, respBytes, ttl).Err()
err := c.rdb.Set(ctx, serial, resp.Raw, ttl).Err()
if err != nil {
state := "failed"
if errors.Is(err, context.DeadlineExceeded) {

View File

@ -11,6 +11,7 @@ import (
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/metrics"
"golang.org/x/crypto/ocsp"
)
func makeClient() (*WritingClient, clock.Clock) {
@ -40,7 +41,12 @@ func makeClient() (*WritingClient, clock.Clock) {
func TestSetAndGet(t *testing.T) {
client, _ := makeClient()
response, err := ioutil.ReadFile("testdata/ocsp.response")
respBytes, err := ioutil.ReadFile("testdata/ocsp.response")
if err != nil {
t.Fatal(err)
}
response, err := ocsp.ParseResponse(respBytes, nil)
if err != nil {
t.Fatal(err)
}
@ -54,7 +60,7 @@ func TestSetAndGet(t *testing.T) {
if err != nil {
t.Fatalf("getting response: %s", err)
}
if !bytes.Equal(resp2, response) {
if !bytes.Equal(resp2, respBytes) {
t.Errorf("response written and response retrieved were not equal")
}
}

View File

@ -2,15 +2,21 @@ package sa
import (
"context"
"golang.org/x/crypto/ocsp"
)
type rocspWriter interface {
StoreResponse(ctx context.Context, respBytes []byte) error
StoreResponse(ctx context.Context, response *ocsp.Response) error
}
// storeOCSPRedis stores an OCSP response in a redis cluster.
func (ssa *SQLStorageAuthority) storeOCSPRedis(ctx context.Context, resp []byte) error {
err := ssa.rocspWriteClient.StoreResponse(ctx, resp)
response, err := ocsp.ParseResponse(resp, nil)
if err != nil {
return err
}
err = ssa.rocspWriteClient.StoreResponse(ctx, response)
if err != nil {
ssa.redisStoreResponse.WithLabelValues("store_response_error").Inc()
return err

View File

@ -18,6 +18,15 @@
"keyFile": "test/redis-tls/boulder/key.pem"
}
},
"tls": {
"caCertFile": "test/grpc-creds/minica.pem",
"certFile": "test/grpc-creds/ocsp-responder.boulder/cert.pem",
"keyFile": "test/grpc-creds/ocsp-responder.boulder/key.pem"
},
"raService": {
"serverAddress": "ra.boulder:9094",
"timeout": "15s"
},
"path": "/",
"listenAddress": "0.0.0.0:4002",
"issuerCerts": [
@ -29,7 +38,10 @@
"timeout": "4.9s",
"shutdownStopTimeout": "10s",
"debugAddr": ":8005",
"requiredSerialPrefixes": ["ff"]
"requiredSerialPrefixes": ["ff"],
"features": {
"ROCSPStage1": true
}
},
"syslog": {

View File

@ -51,6 +51,7 @@
"admin-revoker.boulder",
"bad-key-revoker.boulder",
"health-checker.boulder",
"ocsp-responder.boulder",
"wfe.boulder"
]
},

View File

@ -9,8 +9,8 @@ command -v minica >/dev/null 2>&1 || {
exit 1;
}
for SERVICE in admin-revoker expiration-mailer ocsp-updater orphan-finder wfe \
akamai-purger nonce bad-key-revoker crl-updater health-checker; do
for SERVICE in admin-revoker expiration-mailer ocsp-updater ocsp-responder \
orphan-finder wfe akamai-purger nonce bad-key-revoker crl-updater health-checker; do
minica -domains "${SERVICE}.boulder"
done

View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJTCCAg2gAwIBAgIIZ8Y0hGzPQGUwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgM2I4YjJjMB4XDTIyMDYzMDIzMjgwNVoXDTI0MDcz
MDIzMjgwNVowITEfMB0GA1UEAxMWb2NzcC1yZXNwb25kZXIuYm91bGRlcjCCASIw
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL2yTvRJfDK2mN36MYD7AN+G061U
DMwrOHjujWNcCmcAo4pgj8S6Ug3KbOE966RxbwkZoKdePfSycBwQ4FufrRNJ3Y4L
Hvy2CremCfIU9LH8YcB6jvtT9E6rKhRdR4oOKo5rkK0SZT/iehNB1UDSNF/VMtkI
HX83SgogMpfLDHdmoHSTym7sVxO8Q+1Y56bcRy1VkicCKSL58iAzfrocxbvM0VoM
Oqyzm2PtA1VxrmePdXpS4UJAC9M2aT5bJtfxTvc64qwk1ggNNVOswiJ3kRn51C37
q/fPnGSp7PG9w6rTUVt9AUr5m7QTe5Ist47TpxO8nJ7zYu/2+hGTSrE1eLsCAwEA
AaNiMGAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF
BQcDAjAMBgNVHRMBAf8EAjAAMCEGA1UdEQQaMBiCFm9jc3AtcmVzcG9uZGVyLmJv
dWxkZXIwDQYJKoZIhvcNAQELBQADggEBAHkXCfqD4vykYf4duWvnubqmWpPY7cpx
lJ+8CrYCgZMCpTbjcYqJldbhSl1qnTxD9oTu4LkO2rTsEUn/KJ5f/UsYpXia/1sD
1q6dd2xbXEmCYEcQy4XB2pcLXnrVjU0AHy/co+3bS60XwwTvY4fxWekMe0E0FCkd
X5fMkTv54KToLJE7K4r6rx4wUoi3bfTn/KEsth5GJG4k5yYr4EFgq/RvLZSwbfc1
lZ3dEK3AOJYuto73vtfm5X1TJJCT/FPGZucrnRkEFcKE4LlfwLGxFGkp68gY+opA
vn0DzobF+7bPXBLT8gHPLN9dzSEHMRyKwtFrSgN7OkLxf6CvT93bZ58=
-----END CERTIFICATE-----

View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAvbJO9El8MraY3foxgPsA34bTrVQMzCs4eO6NY1wKZwCjimCP
xLpSDcps4T3rpHFvCRmgp1499LJwHBDgW5+tE0ndjgse/LYKt6YJ8hT0sfxhwHqO
+1P0TqsqFF1Hig4qjmuQrRJlP+J6E0HVQNI0X9Uy2QgdfzdKCiAyl8sMd2agdJPK
buxXE7xD7VjnptxHLVWSJwIpIvnyIDN+uhzFu8zRWgw6rLObY+0DVXGuZ491elLh
QkAL0zZpPlsm1/FO9zrirCTWCA01U6zCIneRGfnULfur98+cZKns8b3DqtNRW30B
SvmbtBN7kiy3jtOnE7ycnvNi7/b6EZNKsTV4uwIDAQABAoIBAQCtYhWySJxhSe5N
LcojhshUhkphqWoNDxAN0JSglVbYsIfKCdo+SMKI4lVERrFj215OpgLmF0hvqMy5
il9Wv4qVD7WWh+mpt8Xx96wtfSPurqy2Hg8j/qdVZDMQ6/VgZPRWulBBMIgkrR9B
DLIbDkZatWZFYpp7VZCx0p4LicZ6Nlow1PxYXsWKOYYbKf0Qq3HA6fr8WHnLJZUa
qnGUiiUptaXx/ycY5Q3KIcWO3WK9Uv5szUPKC2eRRcJbHAhT0LE8A5NH41Ho3FJ2
lVAwZV/hh3h4JtufH5j61onriefR8GUhBDCyREKe6bxE1jMbZ06BTaCuysCO7Wq8
y/tZHnCRAoGBAO4x5nK7RXB21bZksE4Bc8pvS/NybjQseMFA1nh85gP3aS4IDoPM
KGYkXHG1s+YLL0CDI8euurjnRyWmgDjYOQcUwgEVfz8dAFJfFqNh7EN0AS+63gMt
o2MTr4UobrLgYYEQLpI8l8G+1Tmb8oi/fIxlcb354HBVf7Dg13Mdu4PpAoGBAMvg
V6ZFxi9RQWkj4kgVa43Spp6jz2hx1YGKyu43dD+ZToGDLUfLYBrI3kvZOjSCLqiS
DVJ6v98Jb0jsTnWQ8NJmweV77vui2EnbwLaFiwY7CpRFX3MuJ8u+r8HlutWb6DrD
eBqSRPUPqFOIbMZ+kuGQctILSnuvLBdctz4yOGUDAoGBAKiDmxKeVLENEYMZVvXI
5z9XX/dahIba0499LH5Pdndl6P6M6p/ppsckgFZeA2kDjqloXb7eafF4hggn9FzC
9A2DbQFRURW4kcq0xRJPq9PI1TIMVRcQiaAFhE6DXVWlkrW5WglRXtfOB5HbN0nx
ls7I0iBiEJvIkS700tf5N/lJAoGAX3NSNfN23RJi2HHHcE4vA8A66AzzfwfEmRi8
95iY4WnKOpKKsZFDFmcyxDoYqRrF84Alopb7m9WT94VDGoHYbflUEDfc5I0STEoJ
SKrvMuSTiGWOUaOrWBWXveTrezS6HkEDyxTuGfnRqgI2Qxxhch+p0jMdFRknQGzV
EdZ7VrcCgYBWx/pLoIGcWsjl/8MHqh8DylXGudI/SPwhINydT4z5L1xov1Zw65Xa
XVLhWU6VkJ+jeMLBKRxQ+9xgsPELqqYsqS+4AnO1pbogOsInpADm1+3WU/t8F3M1
EV1sHbDK0lAWoBW2GO1YNBRx5WezP6TsrCcwh/hK9AD1aK7/Hakaww==
-----END RSA PRIVATE KEY-----