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  }