...

Source file src/github.com/letsencrypt/boulder/va/caa.go

Documentation: github.com/letsencrypt/boulder/va

     1  package va
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/url"
     7  	"regexp"
     8  	"strings"
     9  	"sync"
    10  
    11  	"github.com/letsencrypt/boulder/core"
    12  	corepb "github.com/letsencrypt/boulder/core/proto"
    13  	berrors "github.com/letsencrypt/boulder/errors"
    14  	"github.com/letsencrypt/boulder/identifier"
    15  	"github.com/letsencrypt/boulder/probs"
    16  	vapb "github.com/letsencrypt/boulder/va/proto"
    17  	"github.com/miekg/dns"
    18  )
    19  
    20  type caaParams struct {
    21  	accountURIID     int64
    22  	validationMethod core.AcmeChallenge
    23  }
    24  
    25  func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsCAAValidRequest) (*vapb.IsCAAValidResponse, error) {
    26  	if req.Domain == "" || req.ValidationMethod == "" || req.AccountURIID == 0 {
    27  		return nil, berrors.InternalServerError("incomplete IsCAAValid request")
    28  	}
    29  
    30  	validationMethod := core.AcmeChallenge(req.ValidationMethod)
    31  	if !validationMethod.IsValid() {
    32  		return nil, berrors.InternalServerError("unrecognized validation method %q", req.ValidationMethod)
    33  	}
    34  
    35  	acmeID := identifier.ACMEIdentifier{
    36  		Type:  identifier.DNS,
    37  		Value: req.Domain,
    38  	}
    39  	params := &caaParams{
    40  		accountURIID:     req.AccountURIID,
    41  		validationMethod: validationMethod,
    42  	}
    43  	if prob := va.checkCAA(ctx, acmeID, params); prob != nil {
    44  		detail := fmt.Sprintf("While processing CAA for %s: %s", req.Domain, prob.Detail)
    45  		return &vapb.IsCAAValidResponse{
    46  			Problem: &corepb.ProblemDetails{
    47  				ProblemType: string(prob.Type),
    48  				Detail:      replaceInvalidUTF8([]byte(detail)),
    49  			},
    50  		}, nil
    51  	}
    52  	return &vapb.IsCAAValidResponse{}, nil
    53  }
    54  
    55  // checkCAA performs a CAA lookup & validation for the provided identifier. If
    56  // the CAA lookup & validation fail a problem is returned.
    57  func (va *ValidationAuthorityImpl) checkCAA(
    58  	ctx context.Context,
    59  	identifier identifier.ACMEIdentifier,
    60  	params *caaParams) *probs.ProblemDetails {
    61  	if params == nil || params.validationMethod == "" || params.accountURIID == 0 {
    62  		return probs.ServerInternal("expected validationMethod or accountURIID not provided to checkCAA")
    63  	}
    64  
    65  	foundAt, valid, response, err := va.checkCAARecords(ctx, identifier, params)
    66  	if err != nil {
    67  		return probs.DNS(err.Error())
    68  	}
    69  
    70  	va.log.AuditInfof("Checked CAA records for %s, [Present: %t, Account ID: %d, Challenge: %s, Valid for issuance: %t, Found at: %q] Response=%q",
    71  		identifier.Value, foundAt != "", params.accountURIID, params.validationMethod, valid, foundAt, response)
    72  	if !valid {
    73  		return probs.CAA(fmt.Sprintf("CAA record for %s prevents issuance", foundAt))
    74  	}
    75  	return nil
    76  }
    77  
    78  // caaResult represents the result of querying CAA for a single name. It breaks
    79  // the CAA resource records down by category, keeping only the issue and
    80  // issuewild records. It also records whether any unrecognized RRs were marked
    81  // critical, and stores the raw response text for logging and debugging.
    82  type caaResult struct {
    83  	name            string
    84  	present         bool
    85  	issue           []*dns.CAA
    86  	issuewild       []*dns.CAA
    87  	criticalUnknown bool
    88  	dig             string
    89  	err             error
    90  }
    91  
    92  // filterCAA processes a set of CAA resource records and picks out the only bits
    93  // we care about. It returns two slices of CAA records, representing the issue
    94  // records and the issuewild records respectively, and a boolean indicating
    95  // whether any unrecognized records had the critical bit set.
    96  func filterCAA(rrs []*dns.CAA) ([]*dns.CAA, []*dns.CAA, bool) {
    97  	var issue, issuewild []*dns.CAA
    98  	var criticalUnknown bool
    99  
   100  	for _, caaRecord := range rrs {
   101  		switch strings.ToLower(caaRecord.Tag) {
   102  		case "issue":
   103  			issue = append(issue, caaRecord)
   104  		case "issuewild":
   105  			issuewild = append(issuewild, caaRecord)
   106  		case "iodef":
   107  			// We support the iodef property tag insofar as we recognize it, but we
   108  			// never choose to send notifications to the specified addresses. So we
   109  			// do not store the contents of the property tag, but also avoid setting
   110  			// the criticalUnknown bit if there are critical iodef tags.
   111  			continue
   112  		default:
   113  			// The critical flag is the bit with significance 128. However, many CAA
   114  			// record users have misinterpreted the RFC and concluded that the bit
   115  			// with significance 1 is the critical bit. This is sufficiently
   116  			// widespread that that bit must reasonably be considered an alias for
   117  			// the critical bit. The remaining bits are 0/ignore as proscribed by the
   118  			// RFC.
   119  			if (caaRecord.Flag & (128 | 1)) != 0 {
   120  				criticalUnknown = true
   121  			}
   122  		}
   123  	}
   124  
   125  	return issue, issuewild, criticalUnknown
   126  }
   127  
   128  // parallelCAALookup makes parallel requests for the target name and all parent
   129  // names. It returns a slice of CAA results, with the results from querying the
   130  // FQDN in the zeroth index, and the results from querying the TLD in the last
   131  // index.
   132  func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string) []caaResult {
   133  	labels := strings.Split(name, ".")
   134  	results := make([]caaResult, len(labels))
   135  	var wg sync.WaitGroup
   136  
   137  	for i := 0; i < len(labels); i++ {
   138  		// Start the concurrent DNS lookup.
   139  		wg.Add(1)
   140  		go func(name string, r *caaResult) {
   141  			r.name = name
   142  			var records []*dns.CAA
   143  			records, r.dig, r.err = va.dnsClient.LookupCAA(ctx, name)
   144  			if len(records) > 0 {
   145  				r.present = true
   146  			}
   147  			r.issue, r.issuewild, r.criticalUnknown = filterCAA(records)
   148  			wg.Done()
   149  		}(strings.Join(labels[i:], "."), &results[i])
   150  	}
   151  
   152  	wg.Wait()
   153  	return results
   154  }
   155  
   156  // selectCAA picks the relevant CAA resource record set to be used, i.e. the set
   157  // for the "closest parent" of the FQDN in question, including the domain
   158  // itself. If we encountered an error for a lookup before we found a successful,
   159  // non-empty response, assume there could have been real records hidden by it,
   160  // and return that error.
   161  func selectCAA(rrs []caaResult) (*caaResult, error) {
   162  	for _, res := range rrs {
   163  		if res.err != nil {
   164  			return nil, res.err
   165  		}
   166  		if res.present {
   167  			return &res, nil
   168  		}
   169  	}
   170  	return nil, nil
   171  }
   172  
   173  // getCAA returns the CAA Relevant Resource Set[1] for the given FQDN, i.e. the
   174  // first CAA RRSet found by traversing upwards from the FQDN by removing the
   175  // leftmost label. It returns nil if no RRSet is found on any parent of the
   176  // given FQDN. The returned result also contains the raw CAA response, and an
   177  // error if one is encountered while querying or parsing the records.
   178  //
   179  // [1]: https://datatracker.ietf.org/doc/html/rfc8659#name-relevant-resource-record-se
   180  func (va *ValidationAuthorityImpl) getCAA(ctx context.Context, hostname string) (*caaResult, error) {
   181  	hostname = strings.TrimRight(hostname, ".")
   182  
   183  	// See RFC 6844 "Certification Authority Processing" for pseudocode, as
   184  	// amended by https://www.rfc-editor.org/errata/eid5065.
   185  	// Essentially: check CAA records for the FDQN to be issued, and all
   186  	// parent domains.
   187  	//
   188  	// The lookups are performed in parallel in order to avoid timing out
   189  	// the RPC call.
   190  	//
   191  	// We depend on our resolver to snap CNAME and DNAME records.
   192  	results := va.parallelCAALookup(ctx, hostname)
   193  	return selectCAA(results)
   194  }
   195  
   196  // checkCAARecords fetches the CAA records for the given identifier and then
   197  // validates them. If the identifier argument's value has a wildcard prefix then
   198  // the prefix is stripped and validation will be performed against the base
   199  // domain, honouring any issueWild CAA records encountered as appropriate.
   200  // checkCAARecords returns four values: the first is a string indicating at
   201  // which name (i.e. FQDN or parent thereof) CAA records were found, if any. The
   202  // second is a bool indicating whether issuance for the identifier is valid. The
   203  // unmodified *dns.CAA records that were processed/filtered are returned as the
   204  // third argument. Any  errors encountered are returned as the fourth return
   205  // value (or nil).
   206  func (va *ValidationAuthorityImpl) checkCAARecords(
   207  	ctx context.Context,
   208  	identifier identifier.ACMEIdentifier,
   209  	params *caaParams) (string, bool, string, error) {
   210  	hostname := strings.ToLower(identifier.Value)
   211  	// If this is a wildcard name, remove the prefix
   212  	var wildcard bool
   213  	if strings.HasPrefix(hostname, `*.`) {
   214  		hostname = strings.TrimPrefix(identifier.Value, `*.`)
   215  		wildcard = true
   216  	}
   217  	caaSet, err := va.getCAA(ctx, hostname)
   218  	if err != nil {
   219  		return "", false, "", err
   220  	}
   221  	raw := ""
   222  	if caaSet != nil {
   223  		raw = caaSet.dig
   224  	}
   225  	valid, foundAt := va.validateCAA(caaSet, wildcard, params)
   226  	return foundAt, valid, raw, nil
   227  }
   228  
   229  // validateCAA checks a provided *caaResult. When the wildcard argument is true
   230  // this means the issueWild records must be validated as well. This function
   231  // returns a boolean indicating whether issuance is allowed by this set of CAA
   232  // records, and a string indicating the name at which the CAA records allowing
   233  // issuance were found (if any -- since finding no records at all allows
   234  // issuance).
   235  func (va *ValidationAuthorityImpl) validateCAA(caaSet *caaResult, wildcard bool, params *caaParams) (bool, string) {
   236  	if caaSet == nil {
   237  		// No CAA records found, can issue
   238  		va.metrics.caaCounter.WithLabelValues("no records").Inc()
   239  		return true, ""
   240  	}
   241  
   242  	if caaSet.criticalUnknown {
   243  		// Contains unknown critical directives
   244  		va.metrics.caaCounter.WithLabelValues("record with unknown critical directive").Inc()
   245  		return false, caaSet.name
   246  	}
   247  
   248  	if len(caaSet.issue) == 0 && !wildcard {
   249  		// Although CAA records exist, none of them pertain to issuance in this case.
   250  		// (e.g. there is only an issuewild directive, but we are checking for a
   251  		// non-wildcard identifier, or there is only an iodef or non-critical unknown
   252  		// directive.)
   253  		va.metrics.caaCounter.WithLabelValues("no relevant records").Inc()
   254  		return true, caaSet.name
   255  	}
   256  
   257  	// Per RFC 8659 Section 5.3:
   258  	//   - "Each issuewild Property MUST be ignored when processing a request for
   259  	//     an FQDN that is not a Wildcard Domain Name."; and
   260  	//   - "If at least one issuewild Property is specified in the Relevant RRset
   261  	//     for a Wildcard Domain Name, each issue Property MUST be ignored when
   262  	//     processing a request for that Wildcard Domain Name."
   263  	// So we default to checking the `caaSet.Issue` records and only check
   264  	// `caaSet.Issuewild` when `wildcard` is true and there are 1 or more
   265  	// `Issuewild` records.
   266  	records := caaSet.issue
   267  	if wildcard && len(caaSet.issuewild) > 0 {
   268  		records = caaSet.issuewild
   269  	}
   270  
   271  	// There are CAA records pertaining to issuance in our case. Note that this
   272  	// includes the case of the unsatisfiable CAA record value ";", used to
   273  	// prevent issuance by any CA under any circumstance.
   274  	//
   275  	// Our CAA identity must be found in the chosen checkSet.
   276  	for _, caa := range records {
   277  		parsedDomain, parsedParams, err := parseCAARecord(caa)
   278  		if err != nil {
   279  			continue
   280  		}
   281  
   282  		if !caaDomainMatches(parsedDomain, va.issuerDomain) {
   283  			continue
   284  		}
   285  
   286  		if !caaAccountURIMatches(parsedParams, va.accountURIPrefixes, params.accountURIID) {
   287  			continue
   288  		}
   289  
   290  		if !caaValidationMethodMatches(parsedParams, params.validationMethod) {
   291  			continue
   292  		}
   293  
   294  		va.metrics.caaCounter.WithLabelValues("authorized").Inc()
   295  		return true, caaSet.name
   296  	}
   297  
   298  	// The list of authorized issuers is non-empty, but we are not in it. Fail.
   299  	va.metrics.caaCounter.WithLabelValues("unauthorized").Inc()
   300  	return false, caaSet.name
   301  }
   302  
   303  // parseCAARecord extracts the domain and parameters (if any) from a
   304  // issue/issuewild CAA record. This follows RFC 8659 Section 4.2 and Section 4.3
   305  // (https://www.rfc-editor.org/rfc/rfc8659.html#section-4). It returns the
   306  // domain name (which may be the empty string if the record forbids issuance)
   307  // and a tag-value map of CAA parameters, or a descriptive error if the record
   308  // is malformed.
   309  func parseCAARecord(caa *dns.CAA) (string, map[string]string, error) {
   310  	isWSP := func(r rune) bool {
   311  		return r == '\t' || r == ' '
   312  	}
   313  
   314  	// Semi-colons (ASCII 0x3B) are prohibited from being specified in the
   315  	// parameter tag or value, hence we can simply split on semi-colons.
   316  	parts := strings.Split(caa.Value, ";")
   317  	domain := strings.TrimFunc(parts[0], isWSP)
   318  	paramList := parts[1:]
   319  	parameters := make(map[string]string)
   320  
   321  	// Handle the case where a semi-colon is specified following the domain
   322  	// but no parameters are given.
   323  	if len(paramList) == 1 && strings.TrimFunc(paramList[0], isWSP) == "" {
   324  		return domain, parameters, nil
   325  	}
   326  
   327  	for _, parameter := range paramList {
   328  		// A parameter tag cannot include equal signs (ASCII 0x3D),
   329  		// however they are permitted in the value itself.
   330  		tv := strings.SplitN(parameter, "=", 2)
   331  		if len(tv) != 2 {
   332  			return "", nil, fmt.Errorf("parameter not formatted as tag=value: %q", parameter)
   333  		}
   334  
   335  		tag := strings.TrimFunc(tv[0], isWSP)
   336  		//lint:ignore S1029,SA6003 we iterate over runes because the RFC specifies ascii codepoints.
   337  		for _, r := range []rune(tag) {
   338  			// ASCII alpha/digits.
   339  			// tag = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT))
   340  			if r < 0x30 || (r > 0x39 && r < 0x41) || (r > 0x5a && r < 0x61) || r > 0x7a {
   341  				return "", nil, fmt.Errorf("tag contains disallowed character: %q", tag)
   342  			}
   343  		}
   344  
   345  		value := strings.TrimFunc(tv[1], isWSP)
   346  		//lint:ignore S1029,SA6003 we iterate over runes because the RFC specifies ascii codepoints.
   347  		for _, r := range []rune(value) {
   348  			// ASCII without whitespace/semi-colons.
   349  			// value = *(%x21-3A / %x3C-7E)
   350  			if r < 0x21 || (r > 0x3a && r < 0x3c) || r > 0x7e {
   351  				return "", nil, fmt.Errorf("value contains disallowed character: %q", value)
   352  			}
   353  		}
   354  
   355  		parameters[tag] = value
   356  	}
   357  
   358  	return domain, parameters, nil
   359  }
   360  
   361  // caaDomainMatches checks that the issuer domain name listed in the parsed
   362  // CAA record matches the domain name we expect.
   363  func caaDomainMatches(caaDomain string, issuerDomain string) bool {
   364  	return caaDomain == issuerDomain
   365  }
   366  
   367  // caaAccountURIMatches checks that the accounturi CAA parameter, if present,
   368  // matches one of the specific account URIs we expect. We support multiple
   369  // account URI prefixes to handle accounts which were registered under ACMEv1.
   370  // See RFC 8657 Section 3: https://www.rfc-editor.org/rfc/rfc8657.html#section-3
   371  func caaAccountURIMatches(caaParams map[string]string, accountURIPrefixes []string, accountID int64) bool {
   372  	accountURI, ok := caaParams["accounturi"]
   373  	if !ok {
   374  		return true
   375  	}
   376  
   377  	// If the accounturi is not formatted according to RFC 3986, reject it.
   378  	_, err := url.Parse(accountURI)
   379  	if err != nil {
   380  		return false
   381  	}
   382  
   383  	for _, prefix := range accountURIPrefixes {
   384  		if accountURI == fmt.Sprintf("%s%d", prefix, accountID) {
   385  			return true
   386  		}
   387  	}
   388  	return false
   389  }
   390  
   391  var validationMethodRegexp = regexp.MustCompile(`^[[:alnum:]-]+$`)
   392  
   393  // caaValidationMethodMatches checks that the validationmethods CAA parameter,
   394  // if present, contains the exact name of the ACME validation method used to
   395  // validate this domain.
   396  // See RFC 8657 Section 4: https://www.rfc-editor.org/rfc/rfc8657.html#section-4
   397  func caaValidationMethodMatches(caaParams map[string]string, method core.AcmeChallenge) bool {
   398  	commaSeparatedMethods, ok := caaParams["validationmethods"]
   399  	if !ok {
   400  		return true
   401  	}
   402  
   403  	for _, m := range strings.Split(commaSeparatedMethods, ",") {
   404  		// If any listed method does not match the ABNF 1*(ALPHA / DIGIT / "-"),
   405  		// immediately reject the whole record.
   406  		if !validationMethodRegexp.MatchString(m) {
   407  			return false
   408  		}
   409  
   410  		caaMethod := core.AcmeChallenge(m)
   411  		if !caaMethod.IsValid() {
   412  			continue
   413  		}
   414  
   415  		if caaMethod == method {
   416  			return true
   417  		}
   418  	}
   419  	return false
   420  }
   421  

View as plain text