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 }