270 lines
7.9 KiB
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
|
|
}
|