github.com/letsencrypt/boulder@v0.20251208.0/core/objects.go (about) 1 package core 2 3 import ( 4 "crypto" 5 "encoding/base64" 6 "encoding/json" 7 "fmt" 8 "hash/fnv" 9 "net/netip" 10 "strings" 11 "time" 12 13 "github.com/go-jose/go-jose/v4" 14 "golang.org/x/crypto/ocsp" 15 16 "github.com/letsencrypt/boulder/identifier" 17 "github.com/letsencrypt/boulder/probs" 18 "github.com/letsencrypt/boulder/revocation" 19 ) 20 21 // AcmeStatus defines the state of a given authorization 22 type AcmeStatus string 23 24 // These statuses are the states of authorizations, challenges, and registrations 25 const ( 26 StatusUnknown = AcmeStatus("unknown") // Unknown status; the default 27 StatusPending = AcmeStatus("pending") // In process; client has next action 28 StatusProcessing = AcmeStatus("processing") // In process; server has next action 29 StatusReady = AcmeStatus("ready") // Order is ready for finalization 30 StatusValid = AcmeStatus("valid") // Object is valid 31 StatusInvalid = AcmeStatus("invalid") // Validation failed 32 StatusRevoked = AcmeStatus("revoked") // Object no longer valid 33 StatusDeactivated = AcmeStatus("deactivated") // Object has been deactivated 34 ) 35 36 // AcmeChallenge values identify different types of ACME challenges 37 type AcmeChallenge string 38 39 // These types are the available challenges 40 const ( 41 ChallengeTypeHTTP01 = AcmeChallenge("http-01") 42 ChallengeTypeDNS01 = AcmeChallenge("dns-01") 43 ChallengeTypeTLSALPN01 = AcmeChallenge("tls-alpn-01") 44 ChallengeTypeDNSAccount01 = AcmeChallenge("dns-account-01") 45 ) 46 47 // IsValid tests whether the challenge is a known challenge 48 func (c AcmeChallenge) IsValid() bool { 49 switch c { 50 case ChallengeTypeHTTP01, ChallengeTypeDNS01, ChallengeTypeTLSALPN01, ChallengeTypeDNSAccount01: 51 return true 52 default: 53 return false 54 } 55 } 56 57 // OCSPStatus defines the state of OCSP for a certificate 58 type OCSPStatus string 59 60 // These status are the states of OCSP 61 const ( 62 OCSPStatusGood = OCSPStatus("good") 63 OCSPStatusRevoked = OCSPStatus("revoked") 64 ) 65 66 var OCSPStatusToInt = map[OCSPStatus]int{ 67 OCSPStatusGood: ocsp.Good, 68 OCSPStatusRevoked: ocsp.Revoked, 69 } 70 71 // DNSPrefix is attached to DNS names in DNS challenges 72 const DNSPrefix = "_acme-challenge" 73 74 type RawCertificateRequest struct { 75 CSR JSONBuffer `json:"csr"` // The encoded CSR 76 } 77 78 // Registration objects represent non-public metadata attached 79 // to account keys. 80 type Registration struct { 81 // Unique identifier 82 ID int64 `json:"-"` 83 84 // Account key to which the details are attached 85 Key *jose.JSONWebKey `json:"key"` 86 87 // Contact URIs 88 Contact *[]string `json:"contact,omitempty"` 89 90 // Agreement with terms of service 91 Agreement string `json:"-"` 92 93 // CreatedAt is the time the registration was created. 94 CreatedAt *time.Time `json:"createdAt,omitempty"` 95 96 Status AcmeStatus `json:"status"` 97 } 98 99 // ValidationRecord represents a validation attempt against a specific URL/hostname 100 // and the IP addresses that were resolved and used. 101 type ValidationRecord struct { 102 // SimpleHTTP only 103 URL string `json:"url,omitempty"` 104 105 // Shared 106 // 107 // Hostname can hold either a DNS name or an IP address. 108 Hostname string `json:"hostname,omitempty"` 109 Port string `json:"port,omitempty"` 110 AddressesResolved []netip.Addr `json:"addressesResolved,omitempty"` 111 AddressUsed netip.Addr `json:"addressUsed"` 112 113 // AddressesTried contains a list of addresses tried before the `AddressUsed`. 114 // Presently this will only ever be one IP from `AddressesResolved` since the 115 // only retry is in the case of a v6 failure with one v4 fallback. E.g. if 116 // a record with `AddressesResolved: { 127.0.0.1, ::1 }` were processed for 117 // a challenge validation with the IPv6 first flag on and the ::1 address 118 // failed but the 127.0.0.1 retry succeeded then the record would end up 119 // being: 120 // { 121 // ... 122 // AddressesResolved: [ 127.0.0.1, ::1 ], 123 // AddressUsed: 127.0.0.1 124 // AddressesTried: [ ::1 ], 125 // ... 126 // } 127 AddressesTried []netip.Addr `json:"addressesTried,omitempty"` 128 129 // ResolverAddrs is the host:port of the DNS resolver(s) that fulfilled the 130 // lookup for AddressUsed. During recursive A and AAAA lookups, a record may 131 // instead look like A:host:port or AAAA:host:port 132 ResolverAddrs []string `json:"resolverAddrs,omitempty"` 133 } 134 135 // Challenge is an aggregate of all data needed for any challenges. 136 // 137 // Rather than define individual types for different types of 138 // challenge, we just throw all the elements into one bucket, 139 // together with the common metadata elements. 140 type Challenge struct { 141 // Type is the type of challenge encoded in this object. 142 Type AcmeChallenge `json:"type"` 143 144 // URL is the URL to which a response can be posted. Required for all types. 145 URL string `json:"url,omitempty"` 146 147 // Status is the status of this challenge. Required for all types. 148 Status AcmeStatus `json:"status,omitempty"` 149 150 // Validated is the time at which the server validated the challenge. Required 151 // if status is valid. 152 Validated *time.Time `json:"validated,omitempty"` 153 154 // Error contains the error that occurred during challenge validation, if any. 155 // If set, the Status must be "invalid". 156 Error *probs.ProblemDetails `json:"error,omitempty"` 157 158 // Token is a random value that uniquely identifies the challenge. It is used 159 // by all current challenges (http-01, tls-alpn-01, and dns-01). 160 Token string `json:"token,omitempty"` 161 162 // Contains information about URLs used or redirected to and IPs resolved and 163 // used 164 ValidationRecord []ValidationRecord `json:"validationRecord,omitempty"` 165 } 166 167 // ExpectedKeyAuthorization computes the expected KeyAuthorization value for 168 // the challenge. 169 func (ch Challenge) ExpectedKeyAuthorization(key *jose.JSONWebKey) (string, error) { 170 if key == nil { 171 return "", fmt.Errorf("Cannot authorize a nil key") 172 } 173 174 thumbprint, err := key.Thumbprint(crypto.SHA256) 175 if err != nil { 176 return "", err 177 } 178 179 return ch.Token + "." + base64.RawURLEncoding.EncodeToString(thumbprint), nil 180 } 181 182 // RecordsSane checks the sanity of a ValidationRecord object before sending it 183 // back to the RA to be stored. 184 func (ch Challenge) RecordsSane() bool { 185 if len(ch.ValidationRecord) == 0 { 186 return false 187 } 188 189 switch ch.Type { 190 case ChallengeTypeHTTP01: 191 for _, rec := range ch.ValidationRecord { 192 // TODO(#7140): Add a check for ResolverAddress == "" only after the 193 // core.proto change has been deployed. 194 if rec.URL == "" || rec.Hostname == "" || rec.Port == "" || (rec.AddressUsed == netip.Addr{}) || 195 len(rec.AddressesResolved) == 0 { 196 return false 197 } 198 } 199 case ChallengeTypeTLSALPN01: 200 if len(ch.ValidationRecord) > 1 { 201 return false 202 } 203 if ch.ValidationRecord[0].URL != "" { 204 return false 205 } 206 // TODO(#7140): Add a check for ResolverAddress == "" only after the 207 // core.proto change has been deployed. 208 if ch.ValidationRecord[0].Hostname == "" || ch.ValidationRecord[0].Port == "" || 209 (ch.ValidationRecord[0].AddressUsed == netip.Addr{}) || len(ch.ValidationRecord[0].AddressesResolved) == 0 { 210 return false 211 } 212 case ChallengeTypeDNS01, ChallengeTypeDNSAccount01: 213 if len(ch.ValidationRecord) > 1 { 214 return false 215 } 216 // TODO(#7140): Add a check for ResolverAddress == "" only after the 217 // core.proto change has been deployed. 218 if ch.ValidationRecord[0].Hostname == "" { 219 return false 220 } 221 return true 222 default: // Unsupported challenge type 223 return false 224 } 225 226 return true 227 } 228 229 // CheckPending ensures that a challenge object is pending and has a token. 230 // This is used before offering the challenge to the client, and before actually 231 // validating a challenge. 232 func (ch Challenge) CheckPending() error { 233 if ch.Status != StatusPending { 234 return fmt.Errorf("challenge is not pending") 235 } 236 237 if !looksLikeAToken(ch.Token) { 238 return fmt.Errorf("token is missing or malformed") 239 } 240 241 return nil 242 } 243 244 // StringID is used to generate a ID for challenges associated with new style authorizations. 245 // This is necessary as these challenges no longer have a unique non-sequential identifier 246 // in the new storage scheme. This identifier is generated by constructing a fnv hash over the 247 // challenge token and type and encoding the first 4 bytes of it using the base64 URL encoding. 248 func (ch Challenge) StringID() string { 249 h := fnv.New128a() 250 h.Write([]byte(ch.Token)) 251 h.Write([]byte(ch.Type)) 252 return base64.RawURLEncoding.EncodeToString(h.Sum(nil)[0:4]) 253 } 254 255 // Authorization represents the authorization of an account key holder to act on 256 // behalf of an identifier. This struct is intended to be used both internally 257 // and for JSON marshaling on the wire. Any fields that should be suppressed on 258 // the wire (e.g., ID, regID) must be made empty before marshaling. 259 type Authorization struct { 260 // An identifier for this authorization, unique across 261 // authorizations and certificates within this instance. 262 ID string `json:"-"` 263 264 // The identifier for which authorization is being given 265 Identifier identifier.ACMEIdentifier `json:"identifier"` 266 267 // The registration ID associated with the authorization 268 RegistrationID int64 `json:"-"` 269 270 // The status of the validation of this authorization 271 Status AcmeStatus `json:"status,omitempty"` 272 273 // The date after which this authorization will be no 274 // longer be considered valid. Note: a certificate may be issued even on the 275 // last day of an authorization's lifetime. The last day for which someone can 276 // hold a valid certificate based on an authorization is authorization 277 // lifetime + certificate lifetime. 278 Expires *time.Time `json:"expires,omitempty"` 279 280 // An array of challenges objects used to validate the 281 // applicant's control of the identifier. For authorizations 282 // in process, these are challenges to be fulfilled; for 283 // final authorizations, they describe the evidence that 284 // the server used in support of granting the authorization. 285 // 286 // There should only ever be one challenge of each type in this 287 // slice and the order of these challenges may not be predictable. 288 Challenges []Challenge `json:"challenges,omitempty"` 289 290 // https://datatracker.ietf.org/doc/html/rfc8555#page-29 291 // 292 // wildcard (optional, boolean): This field MUST be present and true 293 // for authorizations created as a result of a newOrder request 294 // containing a DNS identifier with a value that was a wildcard 295 // domain name. For other authorizations, it MUST be absent. 296 // Wildcard domain names are described in Section 7.1.3. 297 // 298 // This is not represented in the database because we calculate it from 299 // the identifier stored in the database. Unlike the identifier returned 300 // as part of the authorization, the identifier we store in the database 301 // can contain an asterisk. 302 Wildcard bool `json:"wildcard,omitempty"` 303 304 // CertificateProfileName is the name of the profile associated with the 305 // order that first resulted in the creation of this authorization. Omitted 306 // from API responses. 307 CertificateProfileName string `json:"-"` 308 } 309 310 // FindChallengeByStringID will look for a challenge matching the given ID inside 311 // this authorization. If found, it will return the index of that challenge within 312 // the Authorization's Challenges array. Otherwise it will return -1. 313 func (authz *Authorization) FindChallengeByStringID(id string) int { 314 for i, c := range authz.Challenges { 315 if c.StringID() == id { 316 return i 317 } 318 } 319 return -1 320 } 321 322 // SolvedBy will look through the Authorizations challenges, returning the type 323 // of the *first* challenge it finds with Status: valid, or an error if no 324 // challenge is valid. 325 func (authz *Authorization) SolvedBy() (AcmeChallenge, error) { 326 if len(authz.Challenges) == 0 { 327 return "", fmt.Errorf("authorization has no challenges") 328 } 329 for _, chal := range authz.Challenges { 330 if chal.Status == StatusValid { 331 return chal.Type, nil 332 } 333 } 334 return "", fmt.Errorf("authorization not solved by any challenge") 335 } 336 337 // JSONBuffer fields get encoded and decoded JOSE-style, in base64url encoding 338 // with stripped padding. 339 type JSONBuffer []byte 340 341 // MarshalJSON encodes a JSONBuffer for transmission. 342 func (jb JSONBuffer) MarshalJSON() (result []byte, err error) { 343 return json.Marshal(base64.RawURLEncoding.EncodeToString(jb)) 344 } 345 346 // UnmarshalJSON decodes a JSONBuffer to an object. 347 func (jb *JSONBuffer) UnmarshalJSON(data []byte) (err error) { 348 var str string 349 err = json.Unmarshal(data, &str) 350 if err != nil { 351 return err 352 } 353 *jb, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(str, "=")) 354 return 355 } 356 357 // Certificate objects are entirely internal to the server. The only 358 // thing exposed on the wire is the certificate itself. 359 type Certificate struct { 360 ID int64 `db:"id"` 361 RegistrationID int64 `db:"registrationID"` 362 363 Serial string `db:"serial"` 364 Digest string `db:"digest"` 365 DER []byte `db:"der"` 366 Issued time.Time `db:"issued"` 367 Expires time.Time `db:"expires"` 368 } 369 370 // CertificateStatus structs are internal to the server. They represent the 371 // latest data about the status of the certificate, required for generating new 372 // OCSP responses and determining if a certificate has been revoked. 373 type CertificateStatus struct { 374 ID int64 `db:"id"` 375 376 Serial string `db:"serial"` 377 378 // status: 'good' or 'revoked'. Note that good, expired certificates remain 379 // with status 'good' but don't necessarily get fresh OCSP responses. 380 Status OCSPStatus `db:"status"` 381 382 // ocspLastUpdated: The date and time of the last time we generated an OCSP 383 // response. If we have never generated one, this has the zero value of 384 // time.Time, i.e. Jan 1 1970. 385 OCSPLastUpdated time.Time `db:"ocspLastUpdated"` 386 387 // revokedDate: If status is 'revoked', this is the date and time it was 388 // revoked. Otherwise it has the zero value of time.Time, i.e. Jan 1 1970. 389 RevokedDate time.Time `db:"revokedDate"` 390 391 // revokedReason: If status is 'revoked', this is the reason code for the 392 // revocation. Otherwise it is zero (which happens to be the reason 393 // code for 'unspecified'). 394 RevokedReason revocation.Reason `db:"revokedReason"` 395 396 LastExpirationNagSent time.Time `db:"lastExpirationNagSent"` 397 398 // NotAfter and IsExpired are convenience columns which allow expensive 399 // queries to quickly filter out certificates that we don't need to care 400 // about anymore. These are particularly useful for the CRL updater. See 401 // https://github.com/letsencrypt/boulder/issues/1864. 402 NotAfter time.Time `db:"notAfter"` 403 IsExpired bool `db:"isExpired"` 404 405 // Note: this is not an issuance.IssuerNameID because that would create an 406 // import cycle between core and issuance. 407 // Note2: This field used to be called `issuerID`. We keep the old name in 408 // the DB, but update the Go field name to be clear which type of ID this 409 // is. 410 IssuerNameID int64 `db:"issuerID"` 411 } 412 413 // SCTDERs is a convenience type 414 type SCTDERs [][]byte 415 416 // CertDER is a convenience type that helps differentiate what the 417 // underlying byte slice contains 418 type CertDER []byte 419 420 // SuggestedWindow is a type exposed inside the RenewalInfo resource. 421 type SuggestedWindow struct { 422 Start time.Time `json:"start"` 423 End time.Time `json:"end"` 424 } 425 426 // IsWithin returns true if the given time is within the suggested window, 427 // inclusive of the start time and exclusive of the end time. 428 func (window SuggestedWindow) IsWithin(now time.Time) bool { 429 return !now.Before(window.Start) && now.Before(window.End) 430 } 431 432 // RenewalInfo is a type which is exposed to clients which query the renewalInfo 433 // endpoint specified in draft-aaron-ari. 434 type RenewalInfo struct { 435 SuggestedWindow SuggestedWindow `json:"suggestedWindow"` 436 ExplanationURL string `json:"explanationURL,omitempty"` 437 } 438 439 // RenewalInfoSimple constructs a `RenewalInfo` object and suggested window 440 // using a very simple renewal calculation: calculate a point 2/3rds of the way 441 // through the validity period (or halfway through, for short-lived certs), then 442 // give a 2%-of-validity wide window around that. Both the `issued` and 443 // `expires` timestamps are expected to be UTC. 444 func RenewalInfoSimple(issued time.Time, expires time.Time) RenewalInfo { 445 validity := expires.Add(time.Second).Sub(issued) 446 renewalOffset := validity / time.Duration(3) 447 if validity < 10*24*time.Hour { 448 renewalOffset = validity / time.Duration(2) 449 } 450 idealRenewal := expires.Add(-renewalOffset) 451 margin := validity / time.Duration(100) 452 return RenewalInfo{ 453 SuggestedWindow: SuggestedWindow{ 454 Start: idealRenewal.Add(-1 * margin).Truncate(time.Second), 455 End: idealRenewal.Add(margin).Truncate(time.Second), 456 }, 457 } 458 } 459 460 // RenewalInfoImmediate constructs a `RenewalInfo` object with a suggested 461 // window in the past. Per the draft-ietf-acme-ari-01 spec, clients should 462 // attempt to renew immediately if the suggested window is in the past. The 463 // passed `now` is assumed to be a timestamp representing the current moment in 464 // time. The `explanationURL` is an optional URL that the subscriber can use to 465 // learn more about why the renewal is suggested. 466 func RenewalInfoImmediate(now time.Time, explanationURL string) RenewalInfo { 467 oneHourAgo := now.Add(-1 * time.Hour) 468 return RenewalInfo{ 469 SuggestedWindow: SuggestedWindow{ 470 Start: oneHourAgo.Truncate(time.Second), 471 End: oneHourAgo.Add(time.Minute * 30).Truncate(time.Second), 472 }, 473 ExplanationURL: explanationURL, 474 } 475 }