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 }