github.com/letsencrypt/boulder@v0.20251208.0/va/dns.go (about)

     1  package va
     2  
     3  import (
     4  	"context"
     5  	"crypto/sha256"
     6  	"crypto/subtle"
     7  	"encoding/base32"
     8  	"encoding/base64"
     9  	"errors"
    10  	"fmt"
    11  	"net/netip"
    12  	"strings"
    13  
    14  	"github.com/letsencrypt/boulder/bdns"
    15  	"github.com/letsencrypt/boulder/core"
    16  	berrors "github.com/letsencrypt/boulder/errors"
    17  	"github.com/letsencrypt/boulder/identifier"
    18  )
    19  
    20  // getAddr will query for all A/AAAA records associated with hostname and return
    21  // the preferred address, the first netip.Addr in the addrs slice, and all
    22  // addresses resolved. This is the same choice made by the Go internal
    23  // resolution library used by net/http. If there is an error resolving the
    24  // hostname, or if no usable IP addresses are available then a berrors.DNSError
    25  // instance is returned with a nil netip.Addr slice.
    26  func (va ValidationAuthorityImpl) getAddrs(ctx context.Context, hostname string) ([]netip.Addr, bdns.ResolverAddrs, error) {
    27  	addrs, resolvers, err := va.dnsClient.LookupHost(ctx, hostname)
    28  	if err != nil {
    29  		return nil, resolvers, berrors.DNSError("%v", err)
    30  	}
    31  
    32  	if len(addrs) == 0 {
    33  		// This should be unreachable, as no valid IP addresses being found results
    34  		// in an error being returned from LookupHost.
    35  		return nil, resolvers, berrors.DNSError("No valid IP addresses found for %s", hostname)
    36  	}
    37  	va.log.Debugf("Resolved addresses for %s: %s", hostname, addrs)
    38  	return addrs, resolvers, nil
    39  }
    40  
    41  // availableAddresses takes a ValidationRecord and splits the AddressesResolved
    42  // into a list of IPv4 and IPv6 addresses.
    43  func availableAddresses(allAddrs []netip.Addr) (v4 []netip.Addr, v6 []netip.Addr) {
    44  	for _, addr := range allAddrs {
    45  		if addr.Is4() {
    46  			v4 = append(v4, addr)
    47  		} else {
    48  			v6 = append(v6, addr)
    49  		}
    50  	}
    51  	return
    52  }
    53  
    54  // validateDNSAccount01 handles the dns-account-01 challenge by calculating
    55  // the account-specific DNS query domain and expected digest, then calling
    56  // the common DNS validation logic.
    57  // This implements draft-ietf-acme-dns-account-label-01, and is permitted by
    58  // CAB/F Ballot SC-84, which was incorporated into BR v2.1.4.
    59  func (va *ValidationAuthorityImpl) validateDNSAccount01(ctx context.Context, ident identifier.ACMEIdentifier, keyAuthorization string, accountURI string) ([]core.ValidationRecord, error) {
    60  	if ident.Type != identifier.TypeDNS {
    61  		return nil, berrors.MalformedError("Identifier type for DNS-ACCOUNT-01 challenge was not DNS")
    62  	}
    63  	if accountURI == "" {
    64  		return nil, berrors.InternalServerError("accountURI must be provided for dns-account-01")
    65  	}
    66  
    67  	// Calculate the DNS prefix label based on the account URI
    68  	sha256sum := sha256.Sum256([]byte(accountURI))
    69  	prefixBytes := sha256sum[0:10] // First 10 bytes
    70  	prefixLabel := strings.ToLower(base32.StdEncoding.EncodeToString(prefixBytes))
    71  
    72  	// Construct the challenge prefix specific to DNS-ACCOUNT-01
    73  	challengePrefix := fmt.Sprintf("_%s.%s", prefixLabel, core.DNSPrefix)
    74  	va.log.Debugf("DNS-ACCOUNT-01: Querying TXT for %q (derived from account URI %q)", fmt.Sprintf("%s.%s", challengePrefix, ident.Value), accountURI)
    75  
    76  	// Call the common validation logic
    77  	records, err := va.validateDNS(ctx, ident, challengePrefix, keyAuthorization)
    78  	if err != nil {
    79  		// Check if the error returned by validateDNS is of the Unauthorized type
    80  		if errors.Is(err, berrors.Unauthorized) {
    81  			// Enrich any UnauthorizedError from validateDNS with the account URI
    82  			enrichedError := berrors.UnauthorizedError("%s (account: %q)", err.Error(), accountURI)
    83  			return nil, enrichedError
    84  		}
    85  		// For other error types, return as is
    86  		return nil, err
    87  	}
    88  
    89  	return records, nil
    90  }
    91  
    92  func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident identifier.ACMEIdentifier, keyAuthorization string) ([]core.ValidationRecord, error) {
    93  	if ident.Type != identifier.TypeDNS {
    94  		return nil, berrors.MalformedError("Identifier type for DNS-01 challenge was not DNS")
    95  	}
    96  
    97  	// Call the common validation logic
    98  	return va.validateDNS(ctx, ident, core.DNSPrefix, keyAuthorization)
    99  }
   100  
   101  // validateDNS performs the DNS TXT lookup and validation logic.
   102  func (va *ValidationAuthorityImpl) validateDNS(ctx context.Context, ident identifier.ACMEIdentifier, challengePrefix string, keyAuthorization string) ([]core.ValidationRecord, error) {
   103  	// Compute the digest of the key authorization file
   104  	h := sha256.New()
   105  	h.Write([]byte(keyAuthorization))
   106  	authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
   107  
   108  	// Construct the full challenge subdomain by concatenating prefix with identifier
   109  	challengeSubdomain := fmt.Sprintf("%s.%s", challengePrefix, ident.Value)
   110  
   111  	// Look for the required record in the DNS
   112  	txts, resolvers, err := va.dnsClient.LookupTXT(ctx, challengeSubdomain)
   113  	if err != nil {
   114  		return nil, berrors.DNSError("%s", err)
   115  	}
   116  
   117  	// If there weren't any TXT records return a distinct error message to allow
   118  	// troubleshooters to differentiate between no TXT records and
   119  	// invalid/incorrect TXT records.
   120  	if len(txts) == 0 {
   121  		return nil, berrors.UnauthorizedError("No TXT record found at %s", challengeSubdomain)
   122  	}
   123  
   124  	for _, element := range txts {
   125  		if subtle.ConstantTimeCompare([]byte(element), []byte(authorizedKeysDigest)) == 1 {
   126  			// Successful challenge validation
   127  			return []core.ValidationRecord{{Hostname: ident.Value, ResolverAddrs: resolvers}}, nil
   128  		}
   129  	}
   130  
   131  	invalidRecord := txts[0]
   132  	if len(invalidRecord) > 100 {
   133  		invalidRecord = invalidRecord[0:100] + "..."
   134  	}
   135  	var andMore string
   136  	if len(txts) > 1 {
   137  		andMore = fmt.Sprintf(" (and %d more)", len(txts)-1)
   138  	}
   139  	return nil, berrors.UnauthorizedError("Incorrect TXT record %q%s found at %s",
   140  		invalidRecord, andMore, challengeSubdomain)
   141  }