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 }