github.com/letsencrypt/boulder@v0.20251208.0/crl/storer/storer.go (about)

     1  package storer
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/sha256"
     7  	"crypto/x509"
     8  	"encoding/base64"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"math/big"
    13  	"slices"
    14  	"time"
    15  
    16  	"github.com/aws/aws-sdk-go-v2/service/s3"
    17  	"github.com/aws/aws-sdk-go-v2/service/s3/types"
    18  	smithyhttp "github.com/aws/smithy-go/transport/http"
    19  	"github.com/jmhodges/clock"
    20  	"github.com/prometheus/client_golang/prometheus"
    21  	"github.com/prometheus/client_golang/prometheus/promauto"
    22  	"google.golang.org/grpc"
    23  	"google.golang.org/protobuf/types/known/emptypb"
    24  
    25  	"github.com/letsencrypt/boulder/crl"
    26  	"github.com/letsencrypt/boulder/crl/idp"
    27  	cspb "github.com/letsencrypt/boulder/crl/storer/proto"
    28  	"github.com/letsencrypt/boulder/issuance"
    29  	blog "github.com/letsencrypt/boulder/log"
    30  )
    31  
    32  // simpleS3 matches the subset of the s3.Client interface which we use, to allow
    33  // simpler mocking in tests.
    34  type simpleS3 interface {
    35  	PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
    36  	GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
    37  }
    38  
    39  type crlStorer struct {
    40  	cspb.UnsafeCRLStorerServer
    41  	s3Client         simpleS3
    42  	s3Bucket         string
    43  	issuers          map[issuance.NameID]*issuance.Certificate
    44  	uploadCount      *prometheus.CounterVec
    45  	sizeHistogram    *prometheus.HistogramVec
    46  	latencyHistogram *prometheus.HistogramVec
    47  	log              blog.Logger
    48  	clk              clock.Clock
    49  }
    50  
    51  var _ cspb.CRLStorerServer = (*crlStorer)(nil)
    52  
    53  func New(
    54  	issuers []*issuance.Certificate,
    55  	s3Client simpleS3,
    56  	s3Bucket string,
    57  	stats prometheus.Registerer,
    58  	log blog.Logger,
    59  	clk clock.Clock,
    60  ) (*crlStorer, error) {
    61  	issuersByNameID := make(map[issuance.NameID]*issuance.Certificate, len(issuers))
    62  	for _, issuer := range issuers {
    63  		issuersByNameID[issuer.NameID()] = issuer
    64  	}
    65  
    66  	uploadCount := promauto.With(stats).NewCounterVec(prometheus.CounterOpts{
    67  		Name: "crl_storer_uploads",
    68  		Help: "A counter of the number of CRLs uploaded by crl-storer",
    69  	}, []string{"issuer", "result"})
    70  
    71  	sizeHistogram := promauto.With(stats).NewHistogramVec(prometheus.HistogramOpts{
    72  		Name:    "crl_storer_sizes",
    73  		Help:    "A histogram of the sizes (in bytes) of CRLs uploaded by crl-storer",
    74  		Buckets: []float64{0, 256, 1024, 4096, 16384, 65536},
    75  	}, []string{"issuer"})
    76  
    77  	latencyHistogram := promauto.With(stats).NewHistogramVec(prometheus.HistogramOpts{
    78  		Name:    "crl_storer_upload_times",
    79  		Help:    "A histogram of the time (in seconds) it took crl-storer to upload CRLs",
    80  		Buckets: []float64{0.01, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000},
    81  	}, []string{"issuer"})
    82  
    83  	return &crlStorer{
    84  		issuers:          issuersByNameID,
    85  		s3Client:         s3Client,
    86  		s3Bucket:         s3Bucket,
    87  		uploadCount:      uploadCount,
    88  		sizeHistogram:    sizeHistogram,
    89  		latencyHistogram: latencyHistogram,
    90  		log:              log,
    91  		clk:              clk,
    92  	}, nil
    93  }
    94  
    95  // TODO(#6261): Unify all error messages to identify the shard they're working
    96  // on as a JSON object including issuer, crl number, and shard number.
    97  
    98  // UploadCRL implements the gRPC method of the same name. It takes a stream of
    99  // bytes as its input, parses and runs some sanity checks on the CRL, and then
   100  // uploads it to S3.
   101  func (cs *crlStorer) UploadCRL(stream grpc.ClientStreamingServer[cspb.UploadCRLRequest, emptypb.Empty]) error {
   102  	var issuer *issuance.Certificate
   103  	var shardIdx int64
   104  	var crlNumber *big.Int
   105  	crlBytes := make([]byte, 0)
   106  	var cacheControl string
   107  	var expires time.Time
   108  
   109  	// Read all of the messages from the input stream.
   110  	for {
   111  		in, err := stream.Recv()
   112  		if err != nil {
   113  			if err == io.EOF {
   114  				break
   115  			}
   116  			return err
   117  		}
   118  
   119  		switch payload := in.Payload.(type) {
   120  		case *cspb.UploadCRLRequest_Metadata:
   121  			if crlNumber != nil || issuer != nil {
   122  				return errors.New("got more than one metadata message")
   123  			}
   124  			if payload.Metadata.IssuerNameID == 0 || payload.Metadata.Number == 0 {
   125  				return errors.New("got incomplete metadata message")
   126  			}
   127  
   128  			cacheControl = payload.Metadata.CacheControl
   129  			expires = payload.Metadata.Expires.AsTime()
   130  
   131  			shardIdx = payload.Metadata.ShardIdx
   132  			crlNumber = crl.Number(time.Unix(0, payload.Metadata.Number))
   133  
   134  			var ok bool
   135  			issuer, ok = cs.issuers[issuance.NameID(payload.Metadata.IssuerNameID)]
   136  			if !ok {
   137  				return fmt.Errorf("got unrecognized IssuerID: %d", payload.Metadata.IssuerNameID)
   138  			}
   139  
   140  		case *cspb.UploadCRLRequest_CrlChunk:
   141  			crlBytes = append(crlBytes, payload.CrlChunk...)
   142  		}
   143  	}
   144  
   145  	// Do some basic sanity checks on the received metadata and CRL.
   146  	if issuer == nil || crlNumber == nil {
   147  		return errors.New("got no metadata message")
   148  	}
   149  
   150  	crlId := crl.Id(issuer.NameID(), int(shardIdx), crlNumber)
   151  
   152  	cs.sizeHistogram.WithLabelValues(issuer.Subject.CommonName).Observe(float64(len(crlBytes)))
   153  
   154  	crl, err := x509.ParseRevocationList(crlBytes)
   155  	if err != nil {
   156  		return fmt.Errorf("parsing CRL for %s: %w", crlId, err)
   157  	}
   158  
   159  	if crl.Number.Cmp(crlNumber) != 0 {
   160  		return errors.New("got mismatched CRL Number")
   161  	}
   162  
   163  	err = crl.CheckSignatureFrom(issuer.Certificate)
   164  	if err != nil {
   165  		return fmt.Errorf("validating signature for %s: %w", crlId, err)
   166  	}
   167  
   168  	// Before uploading this CRL, we want to compare it against the previous CRL
   169  	// to ensure that the CRL Number field is not going backwards. This is an
   170  	// additional safety check against clock skew and potential races, if multiple
   171  	// crl-updaters are working on the same shard at the same time. We only run
   172  	// these checks if we found a CRL, so we don't block uploading brand new CRLs.
   173  	filename := fmt.Sprintf("%d/%d.crl", issuer.NameID(), shardIdx)
   174  	prevObj, err := cs.s3Client.GetObject(stream.Context(), &s3.GetObjectInput{
   175  		Bucket: &cs.s3Bucket,
   176  		Key:    &filename,
   177  	})
   178  	if err != nil {
   179  		var smithyErr *smithyhttp.ResponseError
   180  		if !errors.As(err, &smithyErr) || smithyErr.HTTPStatusCode() != 404 {
   181  			return fmt.Errorf("getting previous CRL for %s: %w", crlId, err)
   182  		}
   183  		cs.log.Infof("No previous CRL found for %s, proceeding", crlId)
   184  	} else {
   185  		prevBytes, err := io.ReadAll(prevObj.Body)
   186  		if err != nil {
   187  			return fmt.Errorf("downloading previous CRL for %s: %w", crlId, err)
   188  		}
   189  
   190  		prevCRL, err := x509.ParseRevocationList(prevBytes)
   191  		if err != nil {
   192  			return fmt.Errorf("parsing previous CRL for %s: %w", crlId, err)
   193  		}
   194  
   195  		if crl.Number.Cmp(prevCRL.Number) <= 0 {
   196  			return fmt.Errorf("crlNumber not strictly increasing: %d <= %d", crl.Number, prevCRL.Number)
   197  		}
   198  
   199  		idpURIs, err := idp.GetIDPURIs(crl.Extensions)
   200  		if err != nil {
   201  			return fmt.Errorf("getting IDP for %s: %w", crlId, err)
   202  		}
   203  
   204  		prevURIs, err := idp.GetIDPURIs(prevCRL.Extensions)
   205  		if err != nil {
   206  			return fmt.Errorf("getting previous IDP for %s: %w", crlId, err)
   207  		}
   208  
   209  		uriMatch := false
   210  		for _, uri := range idpURIs {
   211  			if slices.Contains(prevURIs, uri) {
   212  				uriMatch = true
   213  				break
   214  			}
   215  		}
   216  		if !uriMatch {
   217  			return fmt.Errorf("IDP does not match previous: %v !∩ %v", idpURIs, prevURIs)
   218  		}
   219  	}
   220  
   221  	// Finally actually upload the new CRL.
   222  	start := cs.clk.Now()
   223  
   224  	checksum := sha256.Sum256(crlBytes)
   225  	checksumb64 := base64.StdEncoding.EncodeToString(checksum[:])
   226  	crlContentType := "application/pkix-crl"
   227  	_, err = cs.s3Client.PutObject(stream.Context(), &s3.PutObjectInput{
   228  		Bucket:            &cs.s3Bucket,
   229  		Key:               &filename,
   230  		Body:              bytes.NewReader(crlBytes),
   231  		ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
   232  		ChecksumSHA256:    &checksumb64,
   233  		ContentType:       &crlContentType,
   234  		Metadata:          map[string]string{"crlNumber": crlNumber.String()},
   235  		Expires:           &expires,
   236  		CacheControl:      &cacheControl,
   237  	})
   238  
   239  	latency := cs.clk.Now().Sub(start)
   240  	cs.latencyHistogram.WithLabelValues(issuer.Subject.CommonName).Observe(latency.Seconds())
   241  
   242  	if err != nil {
   243  		cs.uploadCount.WithLabelValues(issuer.Subject.CommonName, "failed").Inc()
   244  		cs.log.AuditErrf("CRL upload failed: id=[%s] err=[%s]", crlId, err)
   245  		return fmt.Errorf("uploading to S3: %w", err)
   246  	}
   247  
   248  	cs.uploadCount.WithLabelValues(issuer.Subject.CommonName, "success").Inc()
   249  	cs.log.AuditInfof(
   250  		"CRL uploaded: id=[%s] issuerCN=[%s] thisUpdate=[%s] nextUpdate=[%s] numEntries=[%d]",
   251  		crlId, issuer.Subject.CommonName, crl.ThisUpdate, crl.NextUpdate, len(crl.RevokedCertificateEntries),
   252  	)
   253  
   254  	return stream.SendAndClose(&emptypb.Empty{})
   255  }