244 lines
8.2 KiB
Go
244 lines
8.2 KiB
Go
package ctpolicy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
"github.com/letsencrypt/boulder/core"
|
|
"github.com/letsencrypt/boulder/ctpolicy/loglist"
|
|
berrors "github.com/letsencrypt/boulder/errors"
|
|
blog "github.com/letsencrypt/boulder/log"
|
|
pubpb "github.com/letsencrypt/boulder/publisher/proto"
|
|
)
|
|
|
|
const (
|
|
succeeded = "succeeded"
|
|
failed = "failed"
|
|
)
|
|
|
|
// CTPolicy is used to hold information about SCTs required from various
|
|
// groupings
|
|
type CTPolicy struct {
|
|
pub pubpb.PublisherClient
|
|
sctLogs loglist.List
|
|
infoLogs loglist.List
|
|
finalLogs loglist.List
|
|
stagger time.Duration
|
|
log blog.Logger
|
|
winnerCounter *prometheus.CounterVec
|
|
shardExpiryGauge *prometheus.GaugeVec
|
|
}
|
|
|
|
// New creates a new CTPolicy struct
|
|
func New(pub pubpb.PublisherClient, sctLogs loglist.List, infoLogs loglist.List, finalLogs loglist.List, stagger time.Duration, log blog.Logger, stats prometheus.Registerer) *CTPolicy {
|
|
winnerCounter := prometheus.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Name: "sct_winner",
|
|
Help: "Counter of logs which are selected for sct submission, by log URL and result (succeeded or failed).",
|
|
},
|
|
[]string{"url", "result"},
|
|
)
|
|
stats.MustRegister(winnerCounter)
|
|
|
|
shardExpiryGauge := prometheus.NewGaugeVec(
|
|
prometheus.GaugeOpts{
|
|
Name: "ct_shard_expiration_seconds",
|
|
Help: "CT shard end_exclusive field expressed as Unix epoch time, by operator and logID.",
|
|
},
|
|
[]string{"operator", "logID"},
|
|
)
|
|
stats.MustRegister(shardExpiryGauge)
|
|
|
|
for _, log := range sctLogs {
|
|
if log.EndExclusive.IsZero() {
|
|
// Handles the case for non-temporally sharded logs too.
|
|
shardExpiryGauge.WithLabelValues(log.Operator, log.Name).Set(float64(0))
|
|
} else {
|
|
shardExpiryGauge.WithLabelValues(log.Operator, log.Name).Set(float64(log.EndExclusive.Unix()))
|
|
}
|
|
}
|
|
|
|
return &CTPolicy{
|
|
pub: pub,
|
|
sctLogs: sctLogs,
|
|
infoLogs: infoLogs,
|
|
finalLogs: finalLogs,
|
|
stagger: stagger,
|
|
log: log,
|
|
winnerCounter: winnerCounter,
|
|
shardExpiryGauge: shardExpiryGauge,
|
|
}
|
|
}
|
|
|
|
type result struct {
|
|
log loglist.Log
|
|
sct []byte
|
|
err error
|
|
}
|
|
|
|
// GetSCTs retrieves exactly two SCTs from the total collection of configured
|
|
// log groups, with at most one SCT coming from each group. It expects that all
|
|
// logs run by a single operator (e.g. Google) are in the same group, to
|
|
// guarantee that SCTs from logs in different groups do not end up coming from
|
|
// the same operator. As such, it enforces Google's current CT Policy, which
|
|
// requires that certs have two SCTs from logs run by different operators.
|
|
func (ctp *CTPolicy) GetSCTs(ctx context.Context, cert core.CertDER, expiration time.Time) (core.SCTDERs, error) {
|
|
// We'll cancel this sub-context when we have the two SCTs we need, to cause
|
|
// any other ongoing submission attempts to quit.
|
|
subCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// This closure will be called in parallel once for each log.
|
|
getOne := func(i int, l loglist.Log) ([]byte, error) {
|
|
// Sleep a little bit to stagger our requests to the later logs. Use `i-1`
|
|
// to compute the stagger duration so that the first two logs (indices 0
|
|
// and 1) get negative or zero (i.e. instant) sleep durations. If the
|
|
// context gets cancelled (most likely because we got enough SCTs from other
|
|
// logs already) before the sleep is complete, quit instead.
|
|
select {
|
|
case <-subCtx.Done():
|
|
return nil, subCtx.Err()
|
|
case <-time.After(time.Duration(i-1) * ctp.stagger):
|
|
}
|
|
|
|
sct, err := ctp.pub.SubmitToSingleCTWithResult(ctx, &pubpb.Request{
|
|
LogURL: l.Url,
|
|
LogPublicKey: base64.StdEncoding.EncodeToString(l.Key),
|
|
Der: cert,
|
|
Kind: pubpb.SubmissionType_sct,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ct submission to %q (%q) failed: %w", l.Name, l.Url, err)
|
|
}
|
|
|
|
return sct.Sct, nil
|
|
}
|
|
|
|
// Identify the set of candidate logs whose temporal interval includes this
|
|
// cert's expiry. Randomize the order of the logs so that we're not always
|
|
// trying to submit to the same two.
|
|
logs := ctp.sctLogs.ForTime(expiration).Permute()
|
|
|
|
// Kick off a collection of goroutines to try to submit the precert to each
|
|
// log. Ensure that the results channel has a buffer equal to the number of
|
|
// goroutines we're kicking off, so that they're all guaranteed to be able to
|
|
// write to it and exit without blocking and leaking.
|
|
resChan := make(chan result, len(logs))
|
|
for i, log := range logs {
|
|
go func(i int, l loglist.Log) {
|
|
sctDER, err := getOne(i, l)
|
|
resChan <- result{log: l, sct: sctDER, err: err}
|
|
}(i, log)
|
|
}
|
|
|
|
go ctp.submitPrecertInformational(cert, expiration)
|
|
|
|
// Finally, collect SCTs and/or errors from our results channel. We know that
|
|
// we can collect len(logs) results from the channel because every goroutine
|
|
// is guaranteed to write one result (either sct or error) to the channel.
|
|
results := make([]result, 0)
|
|
errs := make([]string, 0)
|
|
for range len(logs) {
|
|
res := <-resChan
|
|
if res.err != nil {
|
|
errs = append(errs, res.err.Error())
|
|
ctp.winnerCounter.WithLabelValues(res.log.Url, failed).Inc()
|
|
continue
|
|
}
|
|
results = append(results, res)
|
|
ctp.winnerCounter.WithLabelValues(res.log.Url, succeeded).Inc()
|
|
|
|
scts := compliantSet(results)
|
|
if scts != nil {
|
|
return scts, nil
|
|
}
|
|
}
|
|
|
|
// If we made it to the end of that loop, that means we never got two SCTs
|
|
// to return. Error out instead.
|
|
if ctx.Err() != nil {
|
|
// We timed out (the calling function returned and canceled our context),
|
|
// thereby causing all of our getOne sub-goroutines to be cancelled.
|
|
return nil, berrors.MissingSCTsError("failed to get 2 SCTs before ctx finished: %s", ctx.Err())
|
|
}
|
|
return nil, berrors.MissingSCTsError("failed to get 2 SCTs, got %d error(s): %s", len(errs), strings.Join(errs, "; "))
|
|
}
|
|
|
|
// compliantSet returns a slice of SCTs which complies with all relevant CT Log
|
|
// Policy requirements, namely that the set of SCTs:
|
|
// - contain at least two SCTs, which
|
|
// - come from logs run by at least two different operators, and
|
|
// - contain at least one RFC6962-compliant (i.e. non-static/tiled) log.
|
|
//
|
|
// If no such set of SCTs exists, returns nil.
|
|
func compliantSet(results []result) core.SCTDERs {
|
|
for _, first := range results {
|
|
if first.err != nil {
|
|
continue
|
|
}
|
|
for _, second := range results {
|
|
if second.err != nil {
|
|
continue
|
|
}
|
|
if first.log.Operator == second.log.Operator {
|
|
// The two SCTs must come from different operators.
|
|
continue
|
|
}
|
|
if first.log.Tiled && second.log.Tiled {
|
|
// At least one must come from a non-tiled log.
|
|
continue
|
|
}
|
|
return core.SCTDERs{first.sct, second.sct}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// submitAllBestEffort submits the given certificate or precertificate to every
|
|
// log ("informational" for precerts, "final" for certs) configured in the policy.
|
|
// It neither waits for these submission to complete, nor tracks their success.
|
|
func (ctp *CTPolicy) submitAllBestEffort(blob core.CertDER, kind pubpb.SubmissionType, expiry time.Time) {
|
|
logs := ctp.finalLogs
|
|
if kind == pubpb.SubmissionType_info {
|
|
logs = ctp.infoLogs
|
|
}
|
|
|
|
for _, log := range logs {
|
|
if log.StartInclusive.After(expiry) || log.EndExclusive.Equal(expiry) || log.EndExclusive.Before(expiry) {
|
|
continue
|
|
}
|
|
|
|
go func(log loglist.Log) {
|
|
_, err := ctp.pub.SubmitToSingleCTWithResult(
|
|
context.Background(),
|
|
&pubpb.Request{
|
|
LogURL: log.Url,
|
|
LogPublicKey: base64.StdEncoding.EncodeToString(log.Key),
|
|
Der: blob,
|
|
Kind: kind,
|
|
},
|
|
)
|
|
if err != nil {
|
|
ctp.log.Warningf("ct submission of cert to log %q failed: %s", log.Url, err)
|
|
}
|
|
}(log)
|
|
}
|
|
}
|
|
|
|
// submitPrecertInformational submits precertificates to any configured
|
|
// "informational" logs, but does not care about success or returned SCTs.
|
|
func (ctp *CTPolicy) submitPrecertInformational(cert core.CertDER, expiration time.Time) {
|
|
ctp.submitAllBestEffort(cert, pubpb.SubmissionType_info, expiration)
|
|
}
|
|
|
|
// SubmitFinalCert submits finalized certificates created from precertificates
|
|
// to any configured "final" logs, but does not care about success.
|
|
func (ctp *CTPolicy) SubmitFinalCert(cert core.CertDER, expiration time.Time) {
|
|
ctp.submitAllBestEffort(cert, pubpb.SubmissionType_final, expiration)
|
|
}
|