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

     1  package ctpolicy
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"fmt"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/prometheus/client_golang/prometheus"
    11  	"github.com/prometheus/client_golang/prometheus/promauto"
    12  
    13  	"github.com/letsencrypt/boulder/core"
    14  	"github.com/letsencrypt/boulder/ctpolicy/loglist"
    15  	berrors "github.com/letsencrypt/boulder/errors"
    16  	blog "github.com/letsencrypt/boulder/log"
    17  	pubpb "github.com/letsencrypt/boulder/publisher/proto"
    18  )
    19  
    20  const (
    21  	succeeded = "succeeded"
    22  	failed    = "failed"
    23  )
    24  
    25  // CTPolicy is used to hold information about SCTs required from various
    26  // groupings
    27  type CTPolicy struct {
    28  	pub              pubpb.PublisherClient
    29  	sctLogs          loglist.List
    30  	infoLogs         loglist.List
    31  	finalLogs        loglist.List
    32  	stagger          time.Duration
    33  	log              blog.Logger
    34  	winnerCounter    *prometheus.CounterVec
    35  	shardExpiryGauge *prometheus.GaugeVec
    36  }
    37  
    38  // New creates a new CTPolicy struct
    39  func New(pub pubpb.PublisherClient, sctLogs loglist.List, infoLogs loglist.List, finalLogs loglist.List, stagger time.Duration, log blog.Logger, stats prometheus.Registerer) *CTPolicy {
    40  	winnerCounter := promauto.With(stats).NewCounterVec(prometheus.CounterOpts{
    41  		Name: "sct_winner",
    42  		Help: "Counter of logs which are selected for sct submission, by log URL and result (succeeded or failed).",
    43  	}, []string{"url", "result"})
    44  
    45  	shardExpiryGauge := promauto.With(stats).NewGaugeVec(prometheus.GaugeOpts{
    46  		Name: "ct_shard_expiration_seconds",
    47  		Help: "CT shard end_exclusive field expressed as Unix epoch time, by operator and logID.",
    48  	}, []string{"operator", "logID"})
    49  
    50  	for _, log := range sctLogs {
    51  		if log.EndExclusive.IsZero() {
    52  			// Handles the case for non-temporally sharded logs too.
    53  			shardExpiryGauge.WithLabelValues(log.Operator, log.Name).Set(float64(0))
    54  		} else {
    55  			shardExpiryGauge.WithLabelValues(log.Operator, log.Name).Set(float64(log.EndExclusive.Unix()))
    56  		}
    57  	}
    58  
    59  	// Stagger must be positive for time.Ticker.
    60  	// Default to the relatively safe value of 1 second.
    61  	if stagger <= 0 {
    62  		stagger = time.Second
    63  	}
    64  
    65  	return &CTPolicy{
    66  		pub:              pub,
    67  		sctLogs:          sctLogs,
    68  		infoLogs:         infoLogs,
    69  		finalLogs:        finalLogs,
    70  		stagger:          stagger,
    71  		log:              log,
    72  		winnerCounter:    winnerCounter,
    73  		shardExpiryGauge: shardExpiryGauge,
    74  	}
    75  }
    76  
    77  type result struct {
    78  	log loglist.Log
    79  	sct []byte
    80  	err error
    81  }
    82  
    83  // getOne obtains an SCT (or error), and returns it in resChan
    84  func (ctp *CTPolicy) getOne(ctx context.Context, cert core.CertDER, l loglist.Log, resChan chan result) {
    85  	sct, err := ctp.pub.SubmitToSingleCTWithResult(ctx, &pubpb.Request{
    86  		LogURL:       l.Url,
    87  		LogPublicKey: base64.StdEncoding.EncodeToString(l.Key),
    88  		Der:          cert,
    89  		Kind:         pubpb.SubmissionType_sct,
    90  	})
    91  	if err != nil {
    92  		resChan <- result{log: l, err: fmt.Errorf("ct submission to %q (%q) failed: %w", l.Name, l.Url, err)}
    93  		return
    94  	}
    95  
    96  	resChan <- result{log: l, sct: sct.Sct}
    97  }
    98  
    99  // GetSCTs retrieves exactly two SCTs from the total collection of configured
   100  // log groups, with at most one SCT coming from each group. It expects that all
   101  // logs run by a single operator (e.g. Google) are in the same group, to
   102  // guarantee that SCTs from logs in different groups do not end up coming from
   103  // the same operator. As such, it enforces Google's current CT Policy, which
   104  // requires that certs have two SCTs from logs run by different operators.
   105  func (ctp *CTPolicy) GetSCTs(ctx context.Context, cert core.CertDER, expiration time.Time) (core.SCTDERs, error) {
   106  	// We'll cancel this sub-context when we have the two SCTs we need, to cause
   107  	// any other ongoing submission attempts to quit.
   108  	subCtx, cancel := context.WithCancel(ctx)
   109  	defer cancel()
   110  
   111  	// Identify the set of candidate logs whose temporal interval includes this
   112  	// cert's expiry. Randomize the order of the logs so that we're not always
   113  	// trying to submit to the same two.
   114  	logs := ctp.sctLogs.ForTime(expiration).Permute()
   115  	if len(logs) < 2 {
   116  		return nil, berrors.MissingSCTsError("Insufficient CT logs available (%d)", len(logs))
   117  	}
   118  
   119  	// Ensure that the results channel has a buffer equal to the number of
   120  	// goroutines we're kicking off, so that they're all guaranteed to be able to
   121  	// write to it and exit without blocking and leaking.
   122  	resChan := make(chan result, len(logs))
   123  
   124  	// Kick off first two submissions
   125  	nextLog := 0
   126  	for ; nextLog < 2; nextLog++ {
   127  		go ctp.getOne(subCtx, cert, logs[nextLog], resChan)
   128  	}
   129  
   130  	go ctp.submitPrecertInformational(cert, expiration)
   131  
   132  	// staggerTicker will be used to start a new submission each stagger interval
   133  	staggerTicker := time.NewTicker(ctp.stagger)
   134  	defer staggerTicker.Stop()
   135  
   136  	// Collect SCTs and errors out of the results channels into these slices.
   137  	results := make([]result, 0)
   138  	errs := make([]string, 0)
   139  
   140  loop:
   141  	for {
   142  		select {
   143  		case <-staggerTicker.C:
   144  			// Each tick from the staggerTicker, we start submitting to another log
   145  			if nextLog >= len(logs) {
   146  				// Unless we have run out of logs to submit to, so don't need to tick anymore
   147  				staggerTicker.Stop()
   148  				continue
   149  			}
   150  			go ctp.getOne(subCtx, cert, logs[nextLog], resChan)
   151  			nextLog++
   152  		case res := <-resChan:
   153  			if res.err != nil {
   154  				errs = append(errs, res.err.Error())
   155  				ctp.winnerCounter.WithLabelValues(res.log.Url, failed).Inc()
   156  			} else {
   157  				results = append(results, res)
   158  				ctp.winnerCounter.WithLabelValues(res.log.Url, succeeded).Inc()
   159  
   160  				scts := compliantSet(results)
   161  				if scts != nil {
   162  					return scts, nil
   163  				}
   164  			}
   165  
   166  			// We can collect len(logs) results from the channel as every goroutine is
   167  			// guaranteed to write one result (either sct or error) to the channel.
   168  			if len(results)+len(errs) >= len(logs) {
   169  				// We have an error or result from every log, but didn't find a compliant set
   170  				break loop
   171  			}
   172  		}
   173  	}
   174  
   175  	// If we made it to the end of that loop, that means we never got two SCTs
   176  	// to return. Error out instead.
   177  	if ctx.Err() != nil {
   178  		// We timed out (the calling function returned and canceled our context),
   179  		// thereby causing all of our getOne sub-goroutines to be cancelled.
   180  		return nil, berrors.MissingSCTsError("failed to get 2 SCTs before ctx finished: %s", ctx.Err())
   181  	}
   182  	return nil, berrors.MissingSCTsError("failed to get 2 SCTs, got %d error(s): %s", len(errs), strings.Join(errs, "; "))
   183  }
   184  
   185  // compliantSet returns a slice of SCTs which complies with all relevant CT Log
   186  // Policy requirements, namely that the set of SCTs:
   187  // - contain at least two SCTs, which
   188  // - come from logs run by at least two different operators, and
   189  // - contain at least one RFC6962-compliant (i.e. non-static/tiled) log.
   190  //
   191  // If no such set of SCTs exists, returns nil.
   192  func compliantSet(results []result) core.SCTDERs {
   193  	for _, first := range results {
   194  		if first.err != nil {
   195  			continue
   196  		}
   197  		for _, second := range results {
   198  			if second.err != nil {
   199  				continue
   200  			}
   201  			if first.log.Operator == second.log.Operator {
   202  				// The two SCTs must come from different operators.
   203  				continue
   204  			}
   205  			if first.log.Tiled && second.log.Tiled {
   206  				// At least one must come from a non-tiled log.
   207  				continue
   208  			}
   209  			return core.SCTDERs{first.sct, second.sct}
   210  		}
   211  	}
   212  	return nil
   213  }
   214  
   215  // submitAllBestEffort submits the given certificate or precertificate to every
   216  // log ("informational" for precerts, "final" for certs) configured in the policy.
   217  // It neither waits for these submission to complete, nor tracks their success.
   218  func (ctp *CTPolicy) submitAllBestEffort(blob core.CertDER, kind pubpb.SubmissionType, expiry time.Time) {
   219  	logs := ctp.finalLogs
   220  	if kind == pubpb.SubmissionType_info {
   221  		logs = ctp.infoLogs
   222  	}
   223  
   224  	for _, log := range logs {
   225  		if log.StartInclusive.After(expiry) || log.EndExclusive.Equal(expiry) || log.EndExclusive.Before(expiry) {
   226  			continue
   227  		}
   228  
   229  		go func(log loglist.Log) {
   230  			_, err := ctp.pub.SubmitToSingleCTWithResult(
   231  				context.Background(),
   232  				&pubpb.Request{
   233  					LogURL:       log.Url,
   234  					LogPublicKey: base64.StdEncoding.EncodeToString(log.Key),
   235  					Der:          blob,
   236  					Kind:         kind,
   237  				},
   238  			)
   239  			if err != nil {
   240  				ctp.log.Warningf("ct submission of cert to log %q failed: %s", log.Url, err)
   241  			}
   242  		}(log)
   243  	}
   244  }
   245  
   246  // submitPrecertInformational submits precertificates to any configured
   247  // "informational" logs, but does not care about success or returned SCTs.
   248  func (ctp *CTPolicy) submitPrecertInformational(cert core.CertDER, expiration time.Time) {
   249  	ctp.submitAllBestEffort(cert, pubpb.SubmissionType_info, expiration)
   250  }
   251  
   252  // SubmitFinalCert submits finalized certificates created from precertificates
   253  // to any configured "final" logs, but does not care about success.
   254  func (ctp *CTPolicy) SubmitFinalCert(cert core.CertDER, expiration time.Time) {
   255  	ctp.submitAllBestEffort(cert, pubpb.SubmissionType_final, expiration)
   256  }