package loglist import ( _ "embed" "encoding/json" "errors" "fmt" "math/rand" "os" "strings" "time" "github.com/letsencrypt/boulder/ctpolicy/loglist/schema" ) // 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 state } // State is an enum representing the various states a CT log can be in. Only // pending, qualified, and usable logs can be submitted to. Only usable and // readonly logs are trusted by Chrome. type state int const ( unknown state = iota pending qualified usable readonly retired rejected ) func stateFromState(s *schema.LogListSchemaJsonOperatorsElemLogsElemState) state { if s == nil { return unknown } else if s.Rejected != nil { return rejected } else if s.Retired != nil { return retired } else if s.Readonly != nil { return readonly } else if s.Pending != nil { return pending } else if s.Qualified != nil { return qualified } else if s.Usable != nil { return usable } return unknown } // usableForPurpose returns true if the log state is acceptable for the given // log list purpose, and false otherwise. func usableForPurpose(s state, p purpose) bool { switch p { case Issuance: return s == usable case Informational: return s == usable || s == qualified || s == pending case Validation: return s == usable || s == readonly } 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) { var parsed schema.LogListSchemaJson err := json.Unmarshal(file, &parsed) 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 { var name string if log.Description != nil { name = *log.Description } info := Log{ Name: name, Url: log.Url, Key: log.Key, State: stateFromState(log.State), } if log.TemporalInterval != nil { startInclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.StartInclusive) if err != nil { return nil, fmt.Errorf("failed to parse log %q start timestamp: %w", log.Url, err) } endExclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.EndExclusive) if err != nil { return nil, fmt.Errorf("failed to parse log %q end timestamp: %w", log.Url, err) } info.StartInclusive = startInclusive info.EndExclusive = endExclusive } group[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, 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 }