github.com/letsencrypt/boulder@v0.20251208.0/publisher/publisher.go (about) 1 package publisher 2 3 import ( 4 "context" 5 "crypto/ecdsa" 6 "crypto/rand" 7 "crypto/sha256" 8 "crypto/tls" 9 "crypto/x509" 10 "encoding/asn1" 11 "encoding/base64" 12 "encoding/json" 13 "errors" 14 "fmt" 15 "math/big" 16 "net/http" 17 "net/url" 18 "strings" 19 "sync" 20 "time" 21 22 ct "github.com/google/certificate-transparency-go" 23 ctClient "github.com/google/certificate-transparency-go/client" 24 "github.com/google/certificate-transparency-go/jsonclient" 25 cttls "github.com/google/certificate-transparency-go/tls" 26 "github.com/prometheus/client_golang/prometheus" 27 "github.com/prometheus/client_golang/prometheus/promauto" 28 29 "github.com/letsencrypt/boulder/core" 30 "github.com/letsencrypt/boulder/issuance" 31 blog "github.com/letsencrypt/boulder/log" 32 "github.com/letsencrypt/boulder/metrics" 33 pubpb "github.com/letsencrypt/boulder/publisher/proto" 34 ) 35 36 // Log contains the CT client for a particular CT log 37 type Log struct { 38 logID string 39 uri string 40 client *ctClient.LogClient 41 } 42 43 // cacheKey is a comparable type for use as a key within a logCache. It holds 44 // both the log URI and its log_id (base64 encoding of its pubkey), so that 45 // the cache won't interfere if the RA decides that a log's URI or pubkey has 46 // changed. 47 type cacheKey struct { 48 uri string 49 pubkey string 50 } 51 52 // logCache contains a cache of *Log's that are constructed as required by 53 // `SubmitToSingleCT` 54 type logCache struct { 55 sync.RWMutex 56 logs map[cacheKey]*Log 57 } 58 59 // AddLog adds a *Log to the cache by constructing the statName, client and 60 // verifier for the given uri & base64 public key. 61 func (c *logCache) AddLog(uri, b64PK, userAgent string, logger blog.Logger) (*Log, error) { 62 // Lock the mutex for reading to check the cache 63 c.RLock() 64 log, present := c.logs[cacheKey{uri, b64PK}] 65 c.RUnlock() 66 67 // If we have already added this log, give it back 68 if present { 69 return log, nil 70 } 71 72 // Lock the mutex for writing to add to the cache 73 c.Lock() 74 defer c.Unlock() 75 76 // Construct a Log, add it to the cache, and return it to the caller 77 log, err := NewLog(uri, b64PK, userAgent, logger) 78 if err != nil { 79 return nil, err 80 } 81 c.logs[cacheKey{uri, b64PK}] = log 82 return log, nil 83 } 84 85 // Len returns the number of logs in the logCache 86 func (c *logCache) Len() int { 87 c.RLock() 88 defer c.RUnlock() 89 return len(c.logs) 90 } 91 92 type logAdaptor struct { 93 blog.Logger 94 } 95 96 func (la logAdaptor) Printf(s string, args ...any) { 97 la.Logger.Infof(s, args...) 98 } 99 100 // NewLog returns an initialized Log struct 101 func NewLog(uri, b64PK, userAgent string, logger blog.Logger) (*Log, error) { 102 url, err := url.Parse(uri) 103 if err != nil { 104 return nil, err 105 } 106 url.Path = strings.TrimSuffix(url.Path, "/") 107 108 derPK, err := base64.StdEncoding.DecodeString(b64PK) 109 if err != nil { 110 return nil, err 111 } 112 113 opts := jsonclient.Options{ 114 Logger: logAdaptor{logger}, 115 PublicKeyDER: derPK, 116 UserAgent: userAgent, 117 } 118 httpClient := &http.Client{ 119 // We set the HTTP client timeout to about half of what we expect 120 // the gRPC timeout to be set to. This allows us to retry the 121 // request at least twice in the case where the server we are 122 // talking to is simply hanging indefinitely. 123 Timeout: time.Minute*2 + time.Second*30, 124 // We provide a new Transport for each Client so that different logs don't 125 // share a connection pool. This shouldn't matter, but we occasionally see a 126 // strange bug where submission to all logs hangs for about fifteen minutes. 127 // One possibility is that there is a strange bug in the locking on 128 // connection pools (possibly triggered by timed-out TCP connections). If 129 // that's the case, separate connection pools should prevent cross-log impact. 130 // We set some fields like TLSHandshakeTimeout to the values from 131 // DefaultTransport because the zero value for these fields means 132 // "unlimited," which would be bad. 133 Transport: &http.Transport{ 134 MaxIdleConns: http.DefaultTransport.(*http.Transport).MaxIdleConns, 135 MaxIdleConnsPerHost: http.DefaultTransport.(*http.Transport).MaxIdleConns, 136 IdleConnTimeout: http.DefaultTransport.(*http.Transport).IdleConnTimeout, 137 TLSHandshakeTimeout: http.DefaultTransport.(*http.Transport).TLSHandshakeTimeout, 138 // In Boulder Issue 3821[0] we found that HTTP/2 support was causing hard 139 // to diagnose intermittent freezes in CT submission. Disabling HTTP/2 with 140 // an environment variable resolved the freezes but is not a stable fix. 141 // 142 // Per the Go `http` package docs we can make this change persistent by 143 // changing the `http.Transport` config: 144 // "Programs that must disable HTTP/2 can do so by setting 145 // Transport.TLSNextProto (for clients) or Server.TLSNextProto (for 146 // servers) to a non-nil, empty map" 147 // 148 // [0]: https://github.com/letsencrypt/boulder/issues/3821 149 TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, 150 }, 151 } 152 client, err := ctClient.New(url.String(), httpClient, opts) 153 if err != nil { 154 return nil, fmt.Errorf("making CT client: %s", err) 155 } 156 157 return &Log{ 158 logID: b64PK, 159 uri: url.String(), 160 client: client, 161 }, nil 162 } 163 164 type ctSubmissionRequest struct { 165 Chain []string `json:"chain"` 166 } 167 168 type pubMetrics struct { 169 submissionLatency *prometheus.HistogramVec 170 probeLatency *prometheus.HistogramVec 171 errorCount *prometheus.CounterVec 172 } 173 174 func initMetrics(stats prometheus.Registerer) *pubMetrics { 175 submissionLatency := promauto.With(stats).NewHistogramVec(prometheus.HistogramOpts{ 176 Name: "ct_submission_time_seconds", 177 Help: "Time taken to submit a certificate to a CT log", 178 Buckets: metrics.InternetFacingBuckets, 179 }, []string{"log", "type", "status", "http_status"}) 180 181 probeLatency := promauto.With(stats).NewHistogramVec(prometheus.HistogramOpts{ 182 Name: "ct_probe_time_seconds", 183 Help: "Time taken to probe a CT log", 184 Buckets: metrics.InternetFacingBuckets, 185 }, []string{"log", "status"}) 186 187 errorCount := promauto.With(stats).NewCounterVec(prometheus.CounterOpts{ 188 Name: "ct_errors_count", 189 Help: "Count of errors by type", 190 }, []string{"log", "type"}) 191 192 return &pubMetrics{submissionLatency, probeLatency, errorCount} 193 } 194 195 // Impl defines a Publisher 196 type Impl struct { 197 pubpb.UnsafePublisherServer 198 log blog.Logger 199 userAgent string 200 issuerBundles map[issuance.NameID][]ct.ASN1Cert 201 ctLogsCache logCache 202 metrics *pubMetrics 203 } 204 205 var _ pubpb.PublisherServer = (*Impl)(nil) 206 207 // New creates a Publisher that will submit certificates 208 // to requested CT logs 209 func New( 210 bundles map[issuance.NameID][]ct.ASN1Cert, 211 userAgent string, 212 logger blog.Logger, 213 stats prometheus.Registerer, 214 ) *Impl { 215 return &Impl{ 216 issuerBundles: bundles, 217 userAgent: userAgent, 218 ctLogsCache: logCache{ 219 logs: make(map[cacheKey]*Log), 220 }, 221 log: logger, 222 metrics: initMetrics(stats), 223 } 224 } 225 226 // SubmitToSingleCTWithResult will submit the certificate represented by certDER 227 // to the CT log specified by log URL and public key (base64) and return the SCT 228 // to the caller. 229 func (pub *Impl) SubmitToSingleCTWithResult(ctx context.Context, req *pubpb.Request) (*pubpb.Result, error) { 230 if core.IsAnyNilOrZero(req.Der, req.LogURL, req.LogPublicKey, req.Kind) { 231 return nil, errors.New("incomplete gRPC request message") 232 } 233 234 cert, err := x509.ParseCertificate(req.Der) 235 if err != nil { 236 pub.log.AuditErrf("Failed to parse certificate: %s", err) 237 return nil, err 238 } 239 240 chain := []ct.ASN1Cert{{Data: req.Der}} 241 id := issuance.IssuerNameID(cert) 242 issuerBundle, ok := pub.issuerBundles[id] 243 if !ok { 244 err := fmt.Errorf("No issuerBundle matching issuerNameID: %d", int64(id)) 245 pub.log.Errf("Failed to submit certificate to CT log: %s", err) 246 return nil, err 247 } 248 chain = append(chain, issuerBundle...) 249 250 // Add a log URL/pubkey to the cache, if already present the 251 // existing *Log will be returned, otherwise one will be constructed, added 252 // and returned. 253 ctLog, err := pub.ctLogsCache.AddLog(req.LogURL, req.LogPublicKey, pub.userAgent, pub.log) 254 if err != nil { 255 pub.log.AuditErrf("Making Log: %s", err) 256 return nil, err 257 } 258 259 sct, err := pub.singleLogSubmit(ctx, chain, req.Kind, ctLog) 260 if err != nil { 261 if core.IsCanceled(err) { 262 return nil, err 263 } 264 var body string 265 var rspErr jsonclient.RspError 266 if errors.As(err, &rspErr) && rspErr.StatusCode < 500 { 267 body = string(rspErr.Body) 268 } 269 pub.log.Errf("Failed to submit certificate to CT log at %s: %s Body=%q", 270 ctLog.uri, err, body) 271 return nil, err 272 } 273 274 sctBytes, err := cttls.Marshal(*sct) 275 if err != nil { 276 return nil, err 277 } 278 return &pubpb.Result{Sct: sctBytes}, nil 279 } 280 281 func (pub *Impl) singleLogSubmit( 282 ctx context.Context, 283 chain []ct.ASN1Cert, 284 kind pubpb.SubmissionType, 285 ctLog *Log, 286 ) (*ct.SignedCertificateTimestamp, error) { 287 submissionMethod := ctLog.client.AddChain 288 if kind == pubpb.SubmissionType_sct || kind == pubpb.SubmissionType_info { 289 submissionMethod = ctLog.client.AddPreChain 290 } 291 292 start := time.Now() 293 sct, err := submissionMethod(ctx, chain) 294 took := time.Since(start).Seconds() 295 if err != nil { 296 status := "error" 297 if core.IsCanceled(err) { 298 status = "canceled" 299 } 300 httpStatus := "" 301 var rspError ctClient.RspError 302 if errors.As(err, &rspError) && rspError.StatusCode != 0 { 303 httpStatus = fmt.Sprintf("%d", rspError.StatusCode) 304 } 305 pub.metrics.submissionLatency.With(prometheus.Labels{ 306 "log": ctLog.uri, 307 "type": kind.String(), 308 "status": status, 309 "http_status": httpStatus, 310 }).Observe(took) 311 pub.metrics.errorCount.With(prometheus.Labels{ 312 "log": ctLog.uri, 313 "type": kind.String(), 314 }).Inc() 315 return nil, err 316 } 317 pub.metrics.submissionLatency.With(prometheus.Labels{ 318 "log": ctLog.uri, 319 "type": kind.String(), 320 "status": "success", 321 "http_status": "", 322 }).Observe(took) 323 324 threshold := uint64(time.Now().Add(time.Minute).UnixMilli()) //nolint: gosec // Current-ish timestamp is guaranteed to fit in a uint64 325 if sct.Timestamp > threshold { 326 return nil, fmt.Errorf("SCT Timestamp was too far in the future (%d > %d)", sct.Timestamp, threshold) 327 } 328 329 // For regular certificates, we could get an old SCT, but that shouldn't 330 // happen for precertificates. 331 threshold = uint64(time.Now().Add(-10 * time.Minute).UnixMilli()) //nolint: gosec // Current-ish timestamp is guaranteed to fit in a uint64 332 if kind != pubpb.SubmissionType_final && sct.Timestamp < threshold { 333 return nil, fmt.Errorf("SCT Timestamp was too far in the past (%d < %d)", sct.Timestamp, threshold) 334 } 335 336 return sct, nil 337 } 338 339 // CreateTestingSignedSCT is used by both the publisher tests and ct-test-serv, which is 340 // why it is exported. It creates a signed SCT based on the provided chain. 341 func CreateTestingSignedSCT(req []string, k *ecdsa.PrivateKey, precert bool, timestamp time.Time) []byte { 342 chain := make([]ct.ASN1Cert, len(req)) 343 for i, str := range req { 344 b, err := base64.StdEncoding.DecodeString(str) 345 if err != nil { 346 panic("cannot decode chain") 347 } 348 chain[i] = ct.ASN1Cert{Data: b} 349 } 350 351 // Generate the internal leaf entry for the SCT 352 etype := ct.X509LogEntryType 353 if precert { 354 etype = ct.PrecertLogEntryType 355 } 356 leaf, err := ct.MerkleTreeLeafFromRawChain(chain, etype, 0) 357 if err != nil { 358 panic(fmt.Sprintf("failed to create leaf: %s", err)) 359 } 360 361 // Sign the SCT 362 rawKey, _ := x509.MarshalPKIXPublicKey(&k.PublicKey) 363 logID := sha256.Sum256(rawKey) 364 timestampMillis := uint64(timestamp.UnixMilli()) //nolint: gosec // Current-ish timestamp is guaranteed to fit in a uint64 365 serialized, _ := ct.SerializeSCTSignatureInput(ct.SignedCertificateTimestamp{ 366 SCTVersion: ct.V1, 367 LogID: ct.LogID{KeyID: logID}, 368 Timestamp: timestampMillis, 369 }, ct.LogEntry{Leaf: *leaf}) 370 hashed := sha256.Sum256(serialized) 371 var ecdsaSig struct { 372 R, S *big.Int 373 } 374 ecdsaSig.R, ecdsaSig.S, _ = ecdsa.Sign(rand.Reader, k, hashed[:]) 375 sig, _ := asn1.Marshal(ecdsaSig) 376 377 // The ct.SignedCertificateTimestamp object doesn't have the needed 378 // `json` tags to properly marshal so we need to transform in into 379 // a struct that does before we can send it off 380 var jsonSCTObj struct { 381 SCTVersion ct.Version `json:"sct_version"` 382 ID string `json:"id"` 383 Timestamp uint64 `json:"timestamp"` 384 Extensions string `json:"extensions"` 385 Signature string `json:"signature"` 386 } 387 jsonSCTObj.SCTVersion = ct.V1 388 jsonSCTObj.ID = base64.StdEncoding.EncodeToString(logID[:]) 389 jsonSCTObj.Timestamp = timestampMillis 390 ds := ct.DigitallySigned{ 391 Algorithm: cttls.SignatureAndHashAlgorithm{ 392 Hash: cttls.SHA256, 393 Signature: cttls.ECDSA, 394 }, 395 Signature: sig, 396 } 397 jsonSCTObj.Signature, _ = ds.Base64String() 398 399 jsonSCT, _ := json.Marshal(jsonSCTObj) 400 return jsonSCT 401 } 402 403 // GetCTBundleForChain takes a slice of *issuance.Certificate(s) 404 // representing a certificate chain and returns a slice of 405 // ct.ASN1Cert(s) in the same order 406 func GetCTBundleForChain(chain []*issuance.Certificate) []ct.ASN1Cert { 407 var ctBundle []ct.ASN1Cert 408 for _, cert := range chain { 409 ctBundle = append(ctBundle, ct.ASN1Cert{Data: cert.Raw}) 410 } 411 return ctBundle 412 }