...

Source file src/github.com/letsencrypt/boulder/ctpolicy/loglist/loglist.go

Documentation: github.com/letsencrypt/boulder/ctpolicy/loglist

     1  package loglist
     2  
     3  import (
     4  	_ "embed"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"math/rand"
     9  	"os"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/letsencrypt/boulder/ctpolicy/loglist/schema"
    14  )
    15  
    16  // purpose is the use to which a log list will be put. This type exists to allow
    17  // the following consts to be declared for use by LogList consumers.
    18  type purpose string
    19  
    20  // Issuance means that the new log list should only contain Usable logs, which
    21  // can issue SCTs that will be trusted by all Chrome clients.
    22  const Issuance purpose = "scts"
    23  
    24  // Informational means that the new log list can contain Usable, Qualified, and
    25  // Pending logs, which will all accept submissions but not necessarily be
    26  // trusted by Chrome clients.
    27  const Informational purpose = "info"
    28  
    29  // Validation means that the new log list should only contain Usable and
    30  // Readonly logs, whose SCTs will be trusted by all Chrome clients but aren't
    31  // necessarily still issuing SCTs today.
    32  const Validation purpose = "lint"
    33  
    34  // List represents a list of logs, grouped by their operator, arranged by
    35  // the "v3" schema as published by Chrome:
    36  // https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
    37  // It exports no fields so that consumers don't have to deal with the terrible
    38  // autogenerated names of the structs it wraps.
    39  type List map[string]OperatorGroup
    40  
    41  // OperatorGroup represents a group of logs which are all run by the same
    42  // operator organization. It provides constant-time lookup of logs within the
    43  // group by their unique ID.
    44  type OperatorGroup map[string]Log
    45  
    46  // Log represents a single log run by an operator. It contains just the info
    47  // necessary to contact a log, and to determine whether that log will accept
    48  // the submission of a certificate with a given expiration.
    49  type Log struct {
    50  	Name           string
    51  	Url            string
    52  	Key            string
    53  	StartInclusive time.Time
    54  	EndExclusive   time.Time
    55  	State          state
    56  }
    57  
    58  // State is an enum representing the various states a CT log can be in. Only
    59  // pending, qualified, and usable logs can be submitted to. Only usable and
    60  // readonly logs are trusted by Chrome.
    61  type state int
    62  
    63  const (
    64  	unknown state = iota
    65  	pending
    66  	qualified
    67  	usable
    68  	readonly
    69  	retired
    70  	rejected
    71  )
    72  
    73  func stateFromState(s *schema.LogListSchemaJsonOperatorsElemLogsElemState) state {
    74  	if s == nil {
    75  		return unknown
    76  	} else if s.Rejected != nil {
    77  		return rejected
    78  	} else if s.Retired != nil {
    79  		return retired
    80  	} else if s.Readonly != nil {
    81  		return readonly
    82  	} else if s.Pending != nil {
    83  		return pending
    84  	} else if s.Qualified != nil {
    85  		return qualified
    86  	} else if s.Usable != nil {
    87  		return usable
    88  	}
    89  	return unknown
    90  }
    91  
    92  // usableForPurpose returns true if the log state is acceptable for the given
    93  // log list purpose, and false otherwise.
    94  func usableForPurpose(s state, p purpose) bool {
    95  	switch p {
    96  	case Issuance:
    97  		return s == usable
    98  	case Informational:
    99  		return s == usable || s == qualified || s == pending
   100  	case Validation:
   101  		return s == usable || s == readonly
   102  	}
   103  	return false
   104  }
   105  
   106  // New returns a LogList of all operators and all logs parsed from the file at
   107  // the given path. The file must conform to the JSON Schema published by Google:
   108  // https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
   109  func New(path string) (List, error) {
   110  	file, err := os.ReadFile(path)
   111  	if err != nil {
   112  		return nil, fmt.Errorf("failed to read CT Log List: %w", err)
   113  	}
   114  
   115  	return newHelper(file)
   116  }
   117  
   118  // newHelper is a helper to allow the core logic of `New()` to be unit tested
   119  // without having to write files to disk.
   120  func newHelper(file []byte) (List, error) {
   121  	var parsed schema.LogListSchemaJson
   122  	err := json.Unmarshal(file, &parsed)
   123  	if err != nil {
   124  		return nil, fmt.Errorf("failed to parse CT Log List: %w", err)
   125  	}
   126  
   127  	result := make(List)
   128  	for _, op := range parsed.Operators {
   129  		group := make(OperatorGroup)
   130  		for _, log := range op.Logs {
   131  			var name string
   132  			if log.Description != nil {
   133  				name = *log.Description
   134  			}
   135  
   136  			info := Log{
   137  				Name:  name,
   138  				Url:   log.Url,
   139  				Key:   log.Key,
   140  				State: stateFromState(log.State),
   141  			}
   142  
   143  			if log.TemporalInterval != nil {
   144  				startInclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.StartInclusive)
   145  				if err != nil {
   146  					return nil, fmt.Errorf("failed to parse log %q start timestamp: %w", log.Url, err)
   147  				}
   148  
   149  				endExclusive, err := time.Parse(time.RFC3339, log.TemporalInterval.EndExclusive)
   150  				if err != nil {
   151  					return nil, fmt.Errorf("failed to parse log %q end timestamp: %w", log.Url, err)
   152  				}
   153  
   154  				info.StartInclusive = startInclusive
   155  				info.EndExclusive = endExclusive
   156  			}
   157  
   158  			group[log.LogId] = info
   159  		}
   160  		result[op.Name] = group
   161  	}
   162  
   163  	return result, nil
   164  }
   165  
   166  // SubsetForPurpose returns a new log list containing only those logs whose
   167  // names match those in the given list, and whose state is acceptable for the
   168  // given purpose. It returns an error if any of the given names are not found
   169  // in the starting list, or if the resulting list is too small to satisfy the
   170  // Chrome "two operators" policy.
   171  func (ll List) SubsetForPurpose(names []string, p purpose) (List, error) {
   172  	sub, err := ll.subset(names)
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  
   177  	res, err := sub.forPurpose(p)
   178  	if err != nil {
   179  		return nil, err
   180  	}
   181  
   182  	return res, nil
   183  }
   184  
   185  // subset returns a new log list containing only those logs whose names match
   186  // those in the given list. It returns an error if any of the given names are
   187  // not found.
   188  func (ll List) subset(names []string) (List, error) {
   189  	remaining := make(map[string]struct{}, len(names))
   190  	for _, name := range names {
   191  		remaining[name] = struct{}{}
   192  	}
   193  
   194  	newList := make(List)
   195  	for operator, group := range ll {
   196  		newGroup := make(OperatorGroup)
   197  		for id, log := range group {
   198  			if _, found := remaining[log.Name]; !found {
   199  				continue
   200  			}
   201  
   202  			newLog := Log{
   203  				Name:           log.Name,
   204  				Url:            log.Url,
   205  				Key:            log.Key,
   206  				State:          log.State,
   207  				StartInclusive: log.StartInclusive,
   208  				EndExclusive:   log.EndExclusive,
   209  			}
   210  
   211  			newGroup[id] = newLog
   212  			delete(remaining, newLog.Name)
   213  		}
   214  		if len(newGroup) > 0 {
   215  			newList[operator] = newGroup
   216  		}
   217  	}
   218  
   219  	if len(remaining) > 0 {
   220  		missed := make([]string, len(remaining))
   221  		for name := range remaining {
   222  			missed = append(missed, fmt.Sprintf("%q", name))
   223  		}
   224  		return nil, fmt.Errorf("failed to find logs matching name(s): %s", strings.Join(missed, ", "))
   225  	}
   226  
   227  	return newList, nil
   228  }
   229  
   230  // forPurpose returns a new log list containing only those logs whose states are
   231  // acceptable for the given purpose. It returns an error if the purpose is
   232  // Issuance or Validation and the set of remaining logs is too small to satisfy
   233  // the Google "two operators" log policy.
   234  func (ll List) forPurpose(p purpose) (List, error) {
   235  	newList := make(List)
   236  	for operator, group := range ll {
   237  		newGroup := make(OperatorGroup)
   238  		for id, log := range group {
   239  			if !usableForPurpose(log.State, p) {
   240  				continue
   241  			}
   242  
   243  			newLog := Log{
   244  				Name:           log.Name,
   245  				Url:            log.Url,
   246  				Key:            log.Key,
   247  				State:          log.State,
   248  				StartInclusive: log.StartInclusive,
   249  				EndExclusive:   log.EndExclusive,
   250  			}
   251  
   252  			newGroup[id] = newLog
   253  		}
   254  		if len(newGroup) > 0 {
   255  			newList[operator] = newGroup
   256  		}
   257  	}
   258  
   259  	if len(newList) < 2 && p != Informational {
   260  		return nil, errors.New("log list does not have enough groups to satisfy Chrome policy")
   261  	}
   262  
   263  	return newList, nil
   264  }
   265  
   266  // OperatorForLogID returns the Name of the Group containing the Log with the
   267  // given ID, or an error if no such log/group can be found.
   268  func (ll List) OperatorForLogID(logID string) (string, error) {
   269  	for op, group := range ll {
   270  		if _, found := group[logID]; found {
   271  			return op, nil
   272  		}
   273  	}
   274  	return "", fmt.Errorf("no log with ID %q found", logID)
   275  }
   276  
   277  // Permute returns the list of operator group names in a randomized order.
   278  func (ll List) Permute() []string {
   279  	keys := make([]string, 0, len(ll))
   280  	for k := range ll {
   281  		keys = append(keys, k)
   282  	}
   283  
   284  	result := make([]string, len(ll))
   285  	for i, j := range rand.Perm(len(ll)) {
   286  		result[i] = keys[j]
   287  	}
   288  	return result
   289  }
   290  
   291  // PickOne returns the URI and Public Key of a single randomly-selected log
   292  // which is run by the given operator and whose temporal interval includes the
   293  // given expiry time. It returns an error if no such log can be found.
   294  func (ll List) PickOne(operator string, expiry time.Time) (string, string, error) {
   295  	group, ok := ll[operator]
   296  	if !ok {
   297  		return "", "", fmt.Errorf("no log operator group named %q", operator)
   298  	}
   299  
   300  	candidates := make([]Log, 0)
   301  	for _, log := range group {
   302  		if log.StartInclusive.IsZero() || log.EndExclusive.IsZero() {
   303  			candidates = append(candidates, log)
   304  			continue
   305  		}
   306  
   307  		if (log.StartInclusive.Equal(expiry) || log.StartInclusive.Before(expiry)) && log.EndExclusive.After(expiry) {
   308  			candidates = append(candidates, log)
   309  		}
   310  	}
   311  
   312  	// Ensure rand.Intn below won't panic.
   313  	if len(candidates) < 1 {
   314  		return "", "", fmt.Errorf("no log found for group %q and expiry %s", operator, expiry)
   315  	}
   316  
   317  	log := candidates[rand.Intn(len(candidates))]
   318  	return log.Url, log.Key, nil
   319  }
   320  

View as plain text