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:
parent
fa2f6174ad
commit
29724cb0b7
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 := ¬FoundRedis{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 = ¬FoundRedis{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 := ¬FoundRedis{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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
"admin-revoker.boulder",
|
||||
"bad-key-revoker.boulder",
|
||||
"health-checker.boulder",
|
||||
"ocsp-responder.boulder",
|
||||
"wfe.boulder"
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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-----
|
|
@ -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-----
|
Loading…
Reference in New Issue