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  }