boulder/ctpolicy/loglist/loglist.go

270 lines
7.9 KiB
Go

package loglist
import (
_ "embed"
"encoding/base64"
"errors"
"fmt"
"math/rand/v2"
"os"
"strings"
"time"
"github.com/google/certificate-transparency-go/loglist3"
)
// purpose is the use to which a log list will be put. This type exists to allow
// the following consts to be declared for use by LogList consumers.
type purpose string
// Issuance means that the new log list should only contain Usable logs, which
// can issue SCTs that will be trusted by all Chrome clients.
const Issuance purpose = "scts"
// Informational means that the new log list can contain Usable, Qualified, and
// Pending logs, which will all accept submissions but not necessarily be
// trusted by Chrome clients.
const Informational purpose = "info"
// Validation means that the new log list should only contain Usable and
// Readonly logs, whose SCTs will be trusted by all Chrome clients but aren't
// necessarily still issuing SCTs today.
const Validation purpose = "lint"
// List represents a list of logs, grouped by their operator, arranged by
// the "v3" schema as published by Chrome:
// https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
// It exports no fields so that consumers don't have to deal with the terrible
// autogenerated names of the structs it wraps.
type List map[string]OperatorGroup
// OperatorGroup represents a group of logs which are all run by the same
// operator organization. It provides constant-time lookup of logs within the
// group by their unique ID.
type OperatorGroup map[string]Log
// Log represents a single log run by an operator. It contains just the info
// necessary to contact a log, and to determine whether that log will accept
// the submission of a certificate with a given expiration.
type Log struct {
Name string
Url string
Key string
StartInclusive time.Time
EndExclusive time.Time
State loglist3.LogStatus
}
// usableForPurpose returns true if the log state is acceptable for the given
// log list purpose, and false otherwise.
func usableForPurpose(s loglist3.LogStatus, p purpose) bool {
switch p {
case Issuance:
return s == loglist3.UsableLogStatus
case Informational:
return s == loglist3.UsableLogStatus || s == loglist3.QualifiedLogStatus || s == loglist3.PendingLogStatus
case Validation:
return s == loglist3.UsableLogStatus || s == loglist3.ReadOnlyLogStatus
}
return false
}
// New returns a LogList of all operators and all logs parsed from the file at
// the given path. The file must conform to the JSON Schema published by Google:
// https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
func New(path string) (List, error) {
file, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read CT Log List: %w", err)
}
return newHelper(file)
}
// newHelper is a helper to allow the core logic of `New()` to be unit tested
// without having to write files to disk.
func newHelper(file []byte) (List, error) {
parsed, err := loglist3.NewFromJSON(file)
if err != nil {
return nil, fmt.Errorf("failed to parse CT Log List: %w", err)
}
result := make(List)
for _, op := range parsed.Operators {
group := make(OperatorGroup)
for _, log := range op.Logs {
info := Log{
Name: log.Description,
Url: log.URL,
Key: base64.StdEncoding.EncodeToString(log.Key),
State: log.State.LogStatus(),
}
if log.TemporalInterval != nil {
info.StartInclusive = log.TemporalInterval.StartInclusive
info.EndExclusive = log.TemporalInterval.EndExclusive
}
group[base64.StdEncoding.EncodeToString(log.LogID)] = info
}
result[op.Name] = group
}
return result, nil
}
// SubsetForPurpose returns a new log list containing only those logs whose
// names match those in the given list, and whose state is acceptable for the
// given purpose. It returns an error if any of the given names are not found
// in the starting list, or if the resulting list is too small to satisfy the
// Chrome "two operators" policy.
func (ll List) SubsetForPurpose(names []string, p purpose) (List, error) {
sub, err := ll.subset(names)
if err != nil {
return nil, err
}
res, err := sub.forPurpose(p)
if err != nil {
return nil, err
}
return res, nil
}
// subset returns a new log list containing only those logs whose names match
// those in the given list. It returns an error if any of the given names are
// not found.
func (ll List) subset(names []string) (List, error) {
remaining := make(map[string]struct{}, len(names))
for _, name := range names {
remaining[name] = struct{}{}
}
newList := make(List)
for operator, group := range ll {
newGroup := make(OperatorGroup)
for id, log := range group {
if _, found := remaining[log.Name]; !found {
continue
}
newLog := Log{
Name: log.Name,
Url: log.Url,
Key: log.Key,
State: log.State,
StartInclusive: log.StartInclusive,
EndExclusive: log.EndExclusive,
}
newGroup[id] = newLog
delete(remaining, newLog.Name)
}
if len(newGroup) > 0 {
newList[operator] = newGroup
}
}
if len(remaining) > 0 {
missed := make([]string, 0, len(remaining))
for name := range remaining {
missed = append(missed, fmt.Sprintf("%q", name))
}
return nil, fmt.Errorf("failed to find logs matching name(s): %s", strings.Join(missed, ", "))
}
return newList, nil
}
// forPurpose returns a new log list containing only those logs whose states are
// acceptable for the given purpose. It returns an error if the purpose is
// Issuance or Validation and the set of remaining logs is too small to satisfy
// the Google "two operators" log policy.
func (ll List) forPurpose(p purpose) (List, error) {
newList := make(List)
for operator, group := range ll {
newGroup := make(OperatorGroup)
for id, log := range group {
if !usableForPurpose(log.State, p) {
continue
}
newLog := Log{
Name: log.Name,
Url: log.Url,
Key: log.Key,
State: log.State,
StartInclusive: log.StartInclusive,
EndExclusive: log.EndExclusive,
}
newGroup[id] = newLog
}
if len(newGroup) > 0 {
newList[operator] = newGroup
}
}
if len(newList) < 2 && p != Informational {
return nil, errors.New("log list does not have enough groups to satisfy Chrome policy")
}
return newList, nil
}
// OperatorForLogID returns the Name of the Group containing the Log with the
// given ID, or an error if no such log/group can be found.
func (ll List) OperatorForLogID(logID string) (string, error) {
for op, group := range ll {
if _, found := group[logID]; found {
return op, nil
}
}
return "", fmt.Errorf("no log with ID %q found", logID)
}
// Permute returns the list of operator group names in a randomized order.
func (ll List) Permute() []string {
keys := make([]string, 0, len(ll))
for k := range ll {
keys = append(keys, k)
}
result := make([]string, len(ll))
for i, j := range rand.Perm(len(ll)) {
result[i] = keys[j]
}
return result
}
// PickOne returns the URI and Public Key of a single randomly-selected log
// which is run by the given operator and whose temporal interval includes the
// given expiry time. It returns an error if no such log can be found.
func (ll List) PickOne(operator string, expiry time.Time) (string, string, error) {
group, ok := ll[operator]
if !ok {
return "", "", fmt.Errorf("no log operator group named %q", operator)
}
candidates := make([]Log, 0)
for _, log := range group {
if log.StartInclusive.IsZero() || log.EndExclusive.IsZero() {
candidates = append(candidates, log)
continue
}
if (log.StartInclusive.Equal(expiry) || log.StartInclusive.Before(expiry)) && log.EndExclusive.After(expiry) {
candidates = append(candidates, log)
}
}
// Ensure rand.Intn below won't panic.
if len(candidates) < 1 {
return "", "", fmt.Errorf("no log found for group %q and expiry %s", operator, expiry)
}
log := candidates[rand.IntN(len(candidates))]
return log.Url, log.Key, nil
}