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 }