github.com/letsencrypt/boulder@v0.20251208.0/va/caa.go (about) 1 package va 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "net/url" 8 "regexp" 9 "strings" 10 "sync" 11 "time" 12 13 "github.com/miekg/dns" 14 "google.golang.org/protobuf/proto" 15 16 "github.com/letsencrypt/boulder/bdns" 17 "github.com/letsencrypt/boulder/core" 18 berrors "github.com/letsencrypt/boulder/errors" 19 bgrpc "github.com/letsencrypt/boulder/grpc" 20 "github.com/letsencrypt/boulder/identifier" 21 "github.com/letsencrypt/boulder/probs" 22 vapb "github.com/letsencrypt/boulder/va/proto" 23 ) 24 25 type caaParams struct { 26 accountURIID int64 27 validationMethod core.AcmeChallenge 28 } 29 30 // DoCAA conducts a CAA check for the specified dnsName. When invoked on the 31 // primary Validation Authority (VA) and the local check succeeds, it also 32 // performs CAA checks using the configured remote VAs. Failed checks are 33 // indicated by a non-nil Problems in the returned ValidationResult. DoCAA 34 // returns error only for internal logic errors (and the client may receive 35 // errors from gRPC in the event of a communication problem). This method 36 // implements the CAA portion of Multi-Perspective Issuance Corroboration as 37 // defined in BRs Sections 3.2.2.9 and 5.4.1. 38 func (va *ValidationAuthorityImpl) DoCAA(ctx context.Context, req *vapb.IsCAAValidRequest) (*vapb.IsCAAValidResponse, error) { 39 if core.IsAnyNilOrZero(req.Identifier, req.ValidationMethod, req.AccountURIID) { 40 return nil, berrors.InternalServerError("incomplete IsCAAValid request") 41 } 42 43 ident := identifier.FromProto(req.Identifier) 44 if ident.Type != identifier.TypeDNS { 45 return nil, berrors.MalformedError("Identifier type for CAA check was not DNS") 46 } 47 48 challType := core.AcmeChallenge(req.ValidationMethod) 49 if !challType.IsValid() { 50 return nil, berrors.InternalServerError("unrecognized validation method %q", req.ValidationMethod) 51 } 52 53 params := &caaParams{ 54 accountURIID: req.AccountURIID, 55 validationMethod: challType, 56 } 57 58 // Initialize variables and a deferred function to handle check latency 59 // metrics, log check errors, and log an MPIC summary. Avoid using := to 60 // redeclare `prob`, `localLatency`, or `summary` below this point. 61 var prob *probs.ProblemDetails 62 var summary *mpicSummary 63 var localLatency time.Duration 64 start := va.clk.Now() 65 logEvent := validationLogEvent{ 66 AuthzID: req.AuthzID, 67 Requester: req.AccountURIID, 68 Identifier: ident, 69 } 70 defer func() { 71 probType := "" 72 outcome := fail 73 if prob != nil { 74 // CAA check failed. 75 probType = string(prob.Type) 76 logEvent.Error = prob.String() 77 } else { 78 // CAA check passed. 79 outcome = pass 80 } 81 82 // Observe local check latency (primary|remote). 83 va.observeLatency(opCAA, va.perspective, string(challType), probType, outcome, localLatency) 84 if va.isPrimaryVA() { 85 // Observe total check latency (primary+remote). 86 va.observeLatency(opCAA, allPerspectives, string(challType), probType, outcome, va.clk.Since(start)) 87 logEvent.Summary = summary 88 } 89 90 // Log the total check latency. 91 logEvent.Latency = va.clk.Since(start).Round(time.Millisecond).Seconds() 92 va.log.AuditObject("CAA check result", logEvent) 93 }() 94 95 // Do the local checks. We do these before kicking off the remote checks to 96 // ensure that we don't waste effort on remote checks if the local ones fail. 97 err := va.checkCAA(ctx, ident, params) 98 99 // Stop the clock for local check latency. 100 localLatency = va.clk.Since(start) 101 102 if err != nil { 103 logEvent.InternalError = err.Error() 104 prob = detailedError(err) 105 prob.Detail = fmt.Sprintf("While processing CAA for %s: %s", ident.Value, prob.Detail) 106 return bgrpc.CAAResultToPB(filterProblemDetails(prob), va.perspective, va.rir) 107 } 108 109 if va.isPrimaryVA() { 110 op := func(ctx context.Context, remoteva RemoteVA, req proto.Message) (remoteResult, error) { 111 checkRequest, ok := req.(*vapb.IsCAAValidRequest) 112 if !ok { 113 return nil, fmt.Errorf("got type %T, want *vapb.IsCAAValidRequest", req) 114 } 115 return remoteva.DoCAA(ctx, checkRequest) 116 } 117 summary, prob = va.doRemoteOperation(ctx, op, req) 118 } 119 120 return bgrpc.CAAResultToPB(filterProblemDetails(prob), va.perspective, va.rir) 121 } 122 123 // checkCAA performs a CAA lookup & validation for the provided identifier. If 124 // the CAA lookup & validation fail a problem is returned. 125 func (va *ValidationAuthorityImpl) checkCAA( 126 ctx context.Context, 127 ident identifier.ACMEIdentifier, 128 params *caaParams) error { 129 if core.IsAnyNilOrZero(params, params.validationMethod, params.accountURIID) { 130 return errors.New("expected validationMethod or accountURIID not provided to checkCAA") 131 } 132 133 foundAt, valid, response, err := va.checkCAARecords(ctx, ident, params) 134 if err != nil { 135 return berrors.DNSError("%s", err) 136 } 137 138 va.log.AuditInfof("Checked CAA records for %s, [Present: %t, Account ID: %d, Challenge: %s, Valid for issuance: %t, Found at: %q] Response=%q", 139 ident.Value, foundAt != "", params.accountURIID, params.validationMethod, valid, foundAt, response) 140 if !valid { 141 return berrors.CAAError("CAA record for %s prevents issuance", foundAt) 142 } 143 return nil 144 } 145 146 // caaResult represents the result of querying CAA for a single name. It breaks 147 // the CAA resource records down by category, keeping only the issue and 148 // issuewild records. It also records whether any unrecognized RRs were marked 149 // critical, and stores the raw response text for logging and debugging. 150 type caaResult struct { 151 name string 152 present bool 153 issue []*dns.CAA 154 issuewild []*dns.CAA 155 criticalUnknown bool 156 dig string 157 resolvers bdns.ResolverAddrs 158 err error 159 } 160 161 // filterCAA processes a set of CAA resource records and picks out the only bits 162 // we care about. It returns two slices of CAA records, representing the issue 163 // records and the issuewild records respectively, and a boolean indicating 164 // whether any unrecognized records had the critical bit set. 165 func filterCAA(rrs []*dns.CAA) ([]*dns.CAA, []*dns.CAA, bool) { 166 var issue, issuewild []*dns.CAA 167 var criticalUnknown bool 168 169 for _, caaRecord := range rrs { 170 switch strings.ToLower(caaRecord.Tag) { 171 case "issue": 172 issue = append(issue, caaRecord) 173 case "issuewild": 174 issuewild = append(issuewild, caaRecord) 175 case "iodef": 176 // We support the iodef property tag insofar as we recognize it, but we 177 // never choose to send notifications to the specified addresses. So we 178 // do not store the contents of the property tag, but also avoid setting 179 // the criticalUnknown bit if there are critical iodef tags. 180 continue 181 case "issuemail", "issuevmc": 182 // We support these property tags insofar as we recognize them and 183 // therefore do not bail out if someone has one marked critical. But 184 // of course we do not do any further processing, as we do not issue 185 // S/MIME or VMC certificates. 186 continue 187 default: 188 // The critical flag is the bit with significance 128. However, many CAA 189 // record users have misinterpreted the RFC and concluded that the bit 190 // with significance 1 is the critical bit. This is sufficiently 191 // widespread that that bit must reasonably be considered an alias for 192 // the critical bit. The remaining bits are 0/ignore as proscribed by the 193 // RFC. 194 if (caaRecord.Flag & (128 | 1)) != 0 { 195 criticalUnknown = true 196 } 197 } 198 } 199 200 return issue, issuewild, criticalUnknown 201 } 202 203 // parallelCAALookup makes parallel requests for the target name and all parent 204 // names. It returns a slice of CAA results, with the results from querying the 205 // FQDN in the zeroth index, and the results from querying the TLD in the last 206 // index. 207 func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string) []caaResult { 208 labels := strings.Split(name, ".") 209 results := make([]caaResult, len(labels)) 210 var wg sync.WaitGroup 211 212 for i := range len(labels) { 213 // Start the concurrent DNS lookup. 214 wg.Add(1) 215 go func(name string, r *caaResult) { 216 r.name = name 217 var records []*dns.CAA 218 records, r.dig, r.resolvers, r.err = va.dnsClient.LookupCAA(ctx, name) 219 if len(records) > 0 { 220 r.present = true 221 } 222 r.issue, r.issuewild, r.criticalUnknown = filterCAA(records) 223 wg.Done() 224 }(strings.Join(labels[i:], "."), &results[i]) 225 } 226 227 wg.Wait() 228 return results 229 } 230 231 // selectCAA picks the relevant CAA resource record set to be used, i.e. the set 232 // for the "closest parent" of the FQDN in question, including the domain 233 // itself. If we encountered an error for a lookup before we found a successful, 234 // non-empty response, assume there could have been real records hidden by it, 235 // and return that error. 236 func selectCAA(rrs []caaResult) (*caaResult, error) { 237 for _, res := range rrs { 238 if res.err != nil { 239 return nil, res.err 240 } 241 if res.present { 242 return &res, nil 243 } 244 } 245 return nil, nil 246 } 247 248 // getCAA returns the CAA Relevant Resource Set[1] for the given FQDN, i.e. the 249 // first CAA RRSet found by traversing upwards from the FQDN by removing the 250 // leftmost label. It returns nil if no RRSet is found on any parent of the 251 // given FQDN. The returned result also contains the raw CAA response, and an 252 // error if one is encountered while querying or parsing the records. 253 // 254 // [1]: https://datatracker.ietf.org/doc/html/rfc8659#name-relevant-resource-record-se 255 func (va *ValidationAuthorityImpl) getCAA(ctx context.Context, hostname string) (*caaResult, error) { 256 hostname = strings.TrimRight(hostname, ".") 257 258 // See RFC 6844 "Certification Authority Processing" for pseudocode, as 259 // amended by https://www.rfc-editor.org/errata/eid5065. 260 // Essentially: check CAA records for the FDQN to be issued, and all 261 // parent domains. 262 // 263 // The lookups are performed in parallel in order to avoid timing out 264 // the RPC call. 265 // 266 // We depend on our resolver to snap CNAME and DNAME records. 267 results := va.parallelCAALookup(ctx, hostname) 268 return selectCAA(results) 269 } 270 271 // checkCAARecords fetches the CAA records for the given identifier and then 272 // validates them. If the identifier argument's value has a wildcard prefix then 273 // the prefix is stripped and validation will be performed against the base 274 // domain, honouring any issueWild CAA records encountered as appropriate. 275 // checkCAARecords returns four values: the first is a string indicating at 276 // which name (i.e. FQDN or parent thereof) CAA records were found, if any. The 277 // second is a bool indicating whether issuance for the identifier is valid. The 278 // unmodified *dns.CAA records that were processed/filtered are returned as the 279 // third argument. Any errors encountered are returned as the fourth return 280 // value (or nil). 281 func (va *ValidationAuthorityImpl) checkCAARecords( 282 ctx context.Context, 283 ident identifier.ACMEIdentifier, 284 params *caaParams) (string, bool, string, error) { 285 hostname := strings.ToLower(ident.Value) 286 // If this is a wildcard name, remove the prefix 287 var wildcard bool 288 if strings.HasPrefix(hostname, `*.`) { 289 hostname = strings.TrimPrefix(ident.Value, `*.`) 290 wildcard = true 291 } 292 caaSet, err := va.getCAA(ctx, hostname) 293 if err != nil { 294 return "", false, "", err 295 } 296 raw := "" 297 if caaSet != nil { 298 raw = caaSet.dig 299 } 300 valid, foundAt := va.validateCAA(caaSet, wildcard, params) 301 return foundAt, valid, raw, nil 302 } 303 304 // validateCAA checks a provided *caaResult. When the wildcard argument is true 305 // this means the issueWild records must be validated as well. This function 306 // returns a boolean indicating whether issuance is allowed by this set of CAA 307 // records, and a string indicating the name at which the CAA records allowing 308 // issuance were found (if any -- since finding no records at all allows 309 // issuance). 310 func (va *ValidationAuthorityImpl) validateCAA(caaSet *caaResult, wildcard bool, params *caaParams) (bool, string) { 311 if caaSet == nil { 312 // No CAA records found, can issue 313 va.metrics.caaCounter.WithLabelValues("no records").Inc() 314 return true, "" 315 } 316 317 if caaSet.criticalUnknown { 318 // Contains unknown critical directives 319 va.metrics.caaCounter.WithLabelValues("record with unknown critical directive").Inc() 320 return false, caaSet.name 321 } 322 323 // Per RFC 8659 Section 5.3: 324 // - "Each issuewild Property MUST be ignored when processing a request for 325 // an FQDN that is not a Wildcard Domain Name."; and 326 // - "If at least one issuewild Property is specified in the Relevant RRset 327 // for a Wildcard Domain Name, each issue Property MUST be ignored when 328 // processing a request for that Wildcard Domain Name." 329 // So we default to checking the `caaSet.Issue` records and only check 330 // `caaSet.Issuewild` when `wildcard` is true and there are 1 or more 331 // `Issuewild` records. 332 records := caaSet.issue 333 if wildcard && len(caaSet.issuewild) > 0 { 334 records = caaSet.issuewild 335 } 336 337 if len(records) == 0 { 338 // Although CAA records exist, none of them pertain to issuance in this case. 339 // (e.g. there is only an issuewild directive, but we are checking for a 340 // non-wildcard identifier, or there is only an iodef or non-critical unknown 341 // directive.) 342 va.metrics.caaCounter.WithLabelValues("no relevant records").Inc() 343 return true, caaSet.name 344 } 345 346 // There are CAA records pertaining to issuance in our case. Note that this 347 // includes the case of the unsatisfiable CAA record value ";", used to 348 // prevent issuance by any CA under any circumstance. 349 // 350 // Our CAA identity must be found in the chosen checkSet. 351 for _, caa := range records { 352 parsedDomain, parsedParams, err := parseCAARecord(caa) 353 if err != nil { 354 continue 355 } 356 357 if !caaDomainMatches(parsedDomain, va.issuerDomain) { 358 continue 359 } 360 361 if !caaAccountURIMatches(parsedParams, va.accountURIPrefixes, params.accountURIID) { 362 continue 363 } 364 365 if !caaValidationMethodMatches(parsedParams, params.validationMethod) { 366 continue 367 } 368 369 va.metrics.caaCounter.WithLabelValues("authorized").Inc() 370 return true, caaSet.name 371 } 372 373 // The list of authorized issuers is non-empty, but we are not in it. Fail. 374 va.metrics.caaCounter.WithLabelValues("unauthorized").Inc() 375 return false, caaSet.name 376 } 377 378 // caaParameter is a key-value pair parsed from a single CAA RR. 379 type caaParameter struct { 380 tag string 381 val string 382 } 383 384 // parseCAARecord extracts the domain and parameters (if any) from a 385 // issue/issuewild CAA record. This follows RFC 8659 Section 4.2 and Section 4.3 386 // (https://www.rfc-editor.org/rfc/rfc8659.html#section-4). It returns the 387 // domain name (which may be the empty string if the record forbids issuance) 388 // and a slice of CAA parameters, or a descriptive error if the record is 389 // malformed. 390 func parseCAARecord(caa *dns.CAA) (string, []caaParameter, error) { 391 isWSP := func(r rune) bool { 392 return r == '\t' || r == ' ' 393 } 394 395 // Semi-colons (ASCII 0x3B) are prohibited from being specified in the 396 // parameter tag or value, hence we can simply split on semi-colons. 397 parts := strings.Split(caa.Value, ";") 398 399 // See https://www.rfc-editor.org/rfc/rfc8659.html#section-4.2 400 // 401 // issuer-domain-name = label *("." label) 402 // label = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT)) 403 issuerDomainName := strings.TrimFunc(parts[0], isWSP) 404 paramList := parts[1:] 405 406 // Handle the case where a semi-colon is specified following the domain 407 // but no parameters are given. 408 if len(paramList) == 1 && strings.TrimFunc(paramList[0], isWSP) == "" { 409 return issuerDomainName, nil, nil 410 } 411 412 var caaParameters []caaParameter 413 for _, parameter := range paramList { 414 // A parameter tag cannot include equal signs (ASCII 0x3D), 415 // however they are permitted in the value itself. 416 tv := strings.SplitN(parameter, "=", 2) 417 if len(tv) != 2 { 418 return "", nil, fmt.Errorf("parameter not formatted as tag=value: %q", parameter) 419 } 420 421 tag := strings.TrimFunc(tv[0], isWSP) 422 //lint:ignore S1029,SA6003 we iterate over runes because the RFC specifies ascii codepoints. 423 for _, r := range []rune(tag) { 424 // ASCII alpha/digits. 425 // tag = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT)) 426 if r < 0x30 || (r > 0x39 && r < 0x41) || (r > 0x5a && r < 0x61) || r > 0x7a { 427 return "", nil, fmt.Errorf("tag contains disallowed character: %q", tag) 428 } 429 } 430 431 value := strings.TrimFunc(tv[1], isWSP) 432 //lint:ignore S1029,SA6003 we iterate over runes because the RFC specifies ascii codepoints. 433 for _, r := range []rune(value) { 434 // ASCII without whitespace/semi-colons. 435 // value = *(%x21-3A / %x3C-7E) 436 if r < 0x21 || (r > 0x3a && r < 0x3c) || r > 0x7e { 437 return "", nil, fmt.Errorf("value contains disallowed character: %q", value) 438 } 439 } 440 441 caaParameters = append(caaParameters, caaParameter{ 442 tag: tag, 443 val: value, 444 }) 445 } 446 447 return issuerDomainName, caaParameters, nil 448 } 449 450 // caaDomainMatches checks that the issuer domain name listed in the parsed 451 // CAA record matches the domain name we expect. 452 func caaDomainMatches(caaDomain string, issuerDomain string) bool { 453 return caaDomain == issuerDomain 454 } 455 456 // caaAccountURIMatches checks that the accounturi CAA parameter, if present, 457 // matches one of the specific account URIs we expect. We support multiple 458 // account URI prefixes to handle accounts which were registered under ACMEv1. 459 // We accept only a single "accounturi" parameter and will fail if multiple are 460 // found in the CAA RR. 461 // See RFC 8657 Section 3: https://www.rfc-editor.org/rfc/rfc8657.html#section-3 462 func caaAccountURIMatches(caaParams []caaParameter, accountURIPrefixes []string, accountID int64) bool { 463 var found bool 464 var accountURI string 465 for _, c := range caaParams { 466 if c.tag == "accounturi" { 467 if found { 468 // A Property with multiple "accounturi" parameters is 469 // unsatisfiable. 470 return false 471 } 472 accountURI = c.val 473 found = true 474 } 475 } 476 477 if !found { 478 // A Property without an "accounturi" parameter matches any account. 479 return true 480 } 481 482 // If the accounturi is not formatted according to RFC 3986, reject it. 483 _, err := url.Parse(accountURI) 484 if err != nil { 485 return false 486 } 487 488 for _, prefix := range accountURIPrefixes { 489 if accountURI == fmt.Sprintf("%s%d", prefix, accountID) { 490 return true 491 } 492 } 493 return false 494 } 495 496 var validationMethodRegexp = regexp.MustCompile(`^[[:alnum:]-]+$`) 497 498 // caaValidationMethodMatches checks that the validationmethods CAA parameter, 499 // if present, contains the exact name of the ACME validation method used to 500 // validate this domain. We accept only a single "validationmethods" parameter 501 // and will fail if multiple are found in the CAA RR, even if all tag-value 502 // pairs would be valid. See RFC 8657 Section 4: 503 // https://www.rfc-editor.org/rfc/rfc8657.html#section-4. 504 func caaValidationMethodMatches(caaParams []caaParameter, method core.AcmeChallenge) bool { 505 var validationMethods string 506 var found bool 507 for _, param := range caaParams { 508 if param.tag == "validationmethods" { 509 if found { 510 // RFC 8657 does not define what behavior to take when multiple 511 // "validationmethods" parameters exist, but we make the 512 // conscious choice to fail validation similar to how multiple 513 // "accounturi" parameters are "unsatisfiable". Subscribers 514 // should be aware of RFC 8657 Section 5.8: 515 // https://www.rfc-editor.org/rfc/rfc8657.html#section-5.8 516 return false 517 } 518 validationMethods = param.val 519 found = true 520 } 521 } 522 523 if !found { 524 return true 525 } 526 527 for m := range strings.SplitSeq(validationMethods, ",") { 528 // The value of the "validationmethods" parameter MUST comply with the 529 // following ABNF [RFC5234]: 530 // 531 // value = [*(label ",") label] 532 // label = 1*(ALPHA / DIGIT / "-") 533 if !validationMethodRegexp.MatchString(m) { 534 return false 535 } 536 537 caaMethod := core.AcmeChallenge(m) 538 if !caaMethod.IsValid() { 539 continue 540 } 541 if caaMethod == method { 542 return true 543 } 544 } 545 546 return false 547 }