github.com/letsencrypt/boulder@v0.20251208.0/ctpolicy/loglist/loglist.go (about)

     1  package loglist
     2  
     3  import (
     4  	_ "embed"
     5  	"encoding/base64"
     6  	"errors"
     7  	"fmt"
     8  	"math/rand/v2"
     9  	"os"
    10  	"slices"
    11  	"time"
    12  
    13  	"github.com/google/certificate-transparency-go/loglist3"
    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 arranged by the "v3" schema as published by
    35  // Chrome: https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
    36  type List []Log
    37  
    38  // Log represents a single log run by an operator. It contains just the info
    39  // necessary to determine whether we want to submit to that log, and how to
    40  // do so.
    41  type Log struct {
    42  	Operator       string
    43  	Name           string
    44  	Id             string
    45  	Key            []byte
    46  	Url            string
    47  	StartInclusive time.Time
    48  	EndExclusive   time.Time
    49  	State          loglist3.LogStatus
    50  	Tiled          bool
    51  	Type           string
    52  }
    53  
    54  // usableForPurpose returns true if the log state is acceptable for the given
    55  // log list purpose, and false otherwise.
    56  func usableForPurpose(s loglist3.LogStatus, p purpose) bool {
    57  	switch p {
    58  	case Issuance:
    59  		return s == loglist3.UsableLogStatus
    60  	case Informational:
    61  		return s == loglist3.UsableLogStatus || s == loglist3.QualifiedLogStatus || s == loglist3.PendingLogStatus
    62  	case Validation:
    63  		return s == loglist3.UsableLogStatus || s == loglist3.ReadOnlyLogStatus
    64  	}
    65  	return false
    66  }
    67  
    68  // isTestLog returns true if the log type is test is "test" or "monitoring_only".
    69  // The schema documents a third option, "prod", which does not currently appear in Google's lists.
    70  func isTestLog(log Log) bool {
    71  	return log.Type == "test" || log.Type == "monitoring_only"
    72  }
    73  
    74  // New returns a LogList of all operators and all logs parsed from the file at
    75  // the given path. The file must conform to the JSON Schema published by Google:
    76  // https://www.gstatic.com/ct/log_list/v3/log_list_schema.json
    77  func New(path string) (List, error) {
    78  	file, err := os.ReadFile(path)
    79  	if err != nil {
    80  		return nil, fmt.Errorf("failed to read CT Log List: %w", err)
    81  	}
    82  
    83  	return newHelper(file)
    84  }
    85  
    86  // newHelper is a helper to allow the core logic of `New()` to be unit tested
    87  // without having to write files to disk.
    88  func newHelper(file []byte) (List, error) {
    89  	parsed, err := loglist3.NewFromJSON(file)
    90  	if err != nil {
    91  		return nil, fmt.Errorf("failed to parse CT Log List: %w", err)
    92  	}
    93  
    94  	result := make(List, 0)
    95  	for _, op := range parsed.Operators {
    96  		for _, log := range op.Logs {
    97  			info := Log{
    98  				Operator: op.Name,
    99  				Name:     log.Description,
   100  				Id:       base64.StdEncoding.EncodeToString(log.LogID),
   101  				Key:      log.Key,
   102  				Url:      log.URL,
   103  				State:    log.State.LogStatus(),
   104  				Tiled:    false,
   105  				Type:     log.Type,
   106  			}
   107  
   108  			if log.TemporalInterval != nil {
   109  				info.StartInclusive = log.TemporalInterval.StartInclusive
   110  				info.EndExclusive = log.TemporalInterval.EndExclusive
   111  			}
   112  
   113  			result = append(result, info)
   114  		}
   115  
   116  		for _, log := range op.TiledLogs {
   117  			info := Log{
   118  				Operator: op.Name,
   119  				Name:     log.Description,
   120  				Id:       base64.StdEncoding.EncodeToString(log.LogID),
   121  				Key:      log.Key,
   122  				Url:      log.SubmissionURL,
   123  				State:    log.State.LogStatus(),
   124  				Tiled:    true,
   125  				Type:     log.Type,
   126  			}
   127  
   128  			if log.TemporalInterval != nil {
   129  				info.StartInclusive = log.TemporalInterval.StartInclusive
   130  				info.EndExclusive = log.TemporalInterval.EndExclusive
   131  			}
   132  
   133  			result = append(result, info)
   134  		}
   135  	}
   136  
   137  	return result, nil
   138  }
   139  
   140  // SubsetForPurpose returns a new log list containing only those logs whose
   141  // names match those in the given list, and whose state is acceptable for the
   142  // given purpose. It returns an error if any of the given names are not found
   143  // in the starting list, or if the resulting list is too small to satisfy the
   144  // Chrome "two operators" policy.
   145  func (ll List) SubsetForPurpose(names []string, p purpose, submitToTestLogs bool) (List, error) {
   146  	sub, err := ll.subset(names)
   147  	if err != nil {
   148  		return nil, err
   149  	}
   150  
   151  	res, err := sub.forPurpose(p, submitToTestLogs)
   152  	if err != nil {
   153  		return nil, err
   154  	}
   155  
   156  	return res, nil
   157  }
   158  
   159  // subset returns a new log list containing only those logs whose names match
   160  // those in the given list. It returns an error if any of the given names are
   161  // not found.
   162  func (ll List) subset(names []string) (List, error) {
   163  	res := make(List, 0)
   164  	for _, name := range names {
   165  		found := false
   166  		for _, log := range ll {
   167  			if log.Name == name {
   168  				if found {
   169  					return nil, fmt.Errorf("found multiple logs matching name %q", name)
   170  				}
   171  				found = true
   172  				res = append(res, log)
   173  			}
   174  		}
   175  		if !found {
   176  			return nil, fmt.Errorf("no log found matching name %q", name)
   177  		}
   178  	}
   179  	return res, nil
   180  }
   181  
   182  // forPurpose returns a new log list containing only those logs whose states are
   183  // acceptable for the given purpose. Test logs are included only when
   184  // submitToTestLogs is true. It returns an error if the purpose is Issuance or
   185  // Validation and the set of remaining logs is too small to satisfy the Google
   186  // "two operators" log policy.
   187  func (ll List) forPurpose(p purpose, submitToTestLogs bool) (List, error) {
   188  	res := make(List, 0)
   189  	operators := make(map[string]struct{})
   190  
   191  	// Test logs in Chrome's all_logs_list.json omit the "state" field. loglist3
   192  	// interprets this as "UndefinedLogStatus", which causes usableForPurpose()
   193  	// to return false. To account for this, we skip this check for test logs.
   194  	for _, log := range ll {
   195  		// Only consider test logs if we are submitting to test logs:
   196  		if isTestLog(log) && !submitToTestLogs {
   197  			continue
   198  		}
   199  		// Check the log is usable for a purpose.
   200  		// But test logs aren't ever marked Usable.
   201  		if !isTestLog(log) && !usableForPurpose(log.State, p) {
   202  			continue
   203  		}
   204  		res = append(res, log)
   205  		operators[log.Operator] = struct{}{}
   206  	}
   207  
   208  	if len(operators) < 2 && p != Informational {
   209  		return nil, errors.New("log list does not have enough groups to satisfy Chrome policy")
   210  	}
   211  
   212  	return res, nil
   213  }
   214  
   215  // ForTime returns a new log list containing only those logs whose temporal
   216  // intervals include the given certificate expiration timestamp.
   217  func (ll List) ForTime(expiry time.Time) List {
   218  	res := slices.Clone(ll)
   219  	res = slices.DeleteFunc(res, func(l Log) bool {
   220  		if (l.StartInclusive.IsZero() || l.StartInclusive.Equal(expiry) || l.StartInclusive.Before(expiry)) &&
   221  			(l.EndExclusive.IsZero() || l.EndExclusive.After(expiry)) {
   222  			return false
   223  		}
   224  		return true
   225  	})
   226  	return res
   227  }
   228  
   229  // Permute returns a new log list containing the exact same logs, but in a
   230  // randomly-shuffled order.
   231  func (ll List) Permute() List {
   232  	res := slices.Clone(ll)
   233  	rand.Shuffle(len(res), func(i int, j int) {
   234  		res[i], res[j] = res[j], res[i]
   235  	})
   236  	return res
   237  }
   238  
   239  // GetByID returns the Log matching the given ID, or an error if no such
   240  // log can be found.
   241  func (ll List) GetByID(logID string) (Log, error) {
   242  	for _, log := range ll {
   243  		if log.Id == logID {
   244  			return log, nil
   245  		}
   246  	}
   247  	return Log{}, fmt.Errorf("no log with ID %q found", logID)
   248  }