boulder/policy/pa.go

658 lines
22 KiB
Go

package policy
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"net/mail"
"os"
"regexp"
"slices"
"strings"
"sync"
"golang.org/x/net/idna"
"golang.org/x/text/unicode/norm"
"github.com/letsencrypt/boulder/bdns"
"github.com/letsencrypt/boulder/core"
berrors "github.com/letsencrypt/boulder/errors"
"github.com/letsencrypt/boulder/iana"
"github.com/letsencrypt/boulder/identifier"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/strictyaml"
)
// AuthorityImpl enforces CA policy decisions.
type AuthorityImpl struct {
log blog.Logger
blocklist map[string]bool
exactBlocklist map[string]bool
wildcardExactBlocklist map[string]bool
blocklistMu sync.RWMutex
enabledChallenges map[core.AcmeChallenge]bool
enabledIdentifiers map[identifier.IdentifierType]bool
}
// New constructs a Policy Authority.
func New(identifierTypes map[identifier.IdentifierType]bool, challengeTypes map[core.AcmeChallenge]bool, log blog.Logger) (*AuthorityImpl, error) {
// If identifierTypes are not configured (i.e. nil), default to allowing DNS
// identifiers. This default is temporary, to improve deployability.
//
// TODO(#8184): Remove this default.
if identifierTypes == nil {
identifierTypes = map[identifier.IdentifierType]bool{identifier.TypeDNS: true}
}
return &AuthorityImpl{
log: log,
enabledChallenges: challengeTypes,
enabledIdentifiers: identifierTypes,
}, nil
}
// blockedNamesPolicy is a struct holding lists of blocked domain names. One for
// exact blocks and one for blocks including all subdomains.
type blockedNamesPolicy struct {
// ExactBlockedNames is a list of domain names. Issuance for names exactly
// matching an entry in the list will be forbidden. (e.g. `ExactBlockedNames`
// containing `www.example.com` will not block `example.com` or
// `mail.example.com`).
ExactBlockedNames []string `yaml:"ExactBlockedNames"`
// HighRiskBlockedNames is like ExactBlockedNames except that issuance is
// blocked for subdomains as well. (e.g. BlockedNames containing `example.com`
// will block `www.example.com`).
//
// This list typically doesn't change with much regularity.
HighRiskBlockedNames []string `yaml:"HighRiskBlockedNames"`
// AdminBlockedNames operates the same as BlockedNames but is changed with more
// frequency based on administrative blocks/revocations that are added over
// time above and beyond the high-risk domains. Managing these entries separately
// from HighRiskBlockedNames makes it easier to vet changes accurately.
AdminBlockedNames []string `yaml:"AdminBlockedNames"`
}
// LoadHostnamePolicyFile will load the given policy file, returning an error if
// it fails.
func (pa *AuthorityImpl) LoadHostnamePolicyFile(f string) error {
configBytes, err := os.ReadFile(f)
if err != nil {
return err
}
hash := sha256.Sum256(configBytes)
pa.log.Infof("loading hostname policy, sha256: %s", hex.EncodeToString(hash[:]))
var policy blockedNamesPolicy
err = strictyaml.Unmarshal(configBytes, &policy)
if err != nil {
return err
}
if len(policy.HighRiskBlockedNames) == 0 {
return fmt.Errorf("No entries in HighRiskBlockedNames.")
}
if len(policy.ExactBlockedNames) == 0 {
return fmt.Errorf("No entries in ExactBlockedNames.")
}
return pa.processHostnamePolicy(policy)
}
// processHostnamePolicy handles loading a new blockedNamesPolicy into the PA.
// All of the policy.ExactBlockedNames will be added to the
// wildcardExactBlocklist by processHostnamePolicy to ensure that wildcards for
// exact blocked names entries are forbidden.
func (pa *AuthorityImpl) processHostnamePolicy(policy blockedNamesPolicy) error {
nameMap := make(map[string]bool)
for _, v := range policy.HighRiskBlockedNames {
nameMap[v] = true
}
for _, v := range policy.AdminBlockedNames {
nameMap[v] = true
}
exactNameMap := make(map[string]bool)
wildcardNameMap := make(map[string]bool)
for _, v := range policy.ExactBlockedNames {
exactNameMap[v] = true
// Remove the leftmost label of the exact blocked names entry to make an exact
// wildcard block list entry that will prevent issuing a wildcard that would
// include the exact blocklist entry. e.g. if "highvalue.example.com" is on
// the exact blocklist we want "example.com" to be in the
// wildcardExactBlocklist so that "*.example.com" cannot be issued.
//
// First, split the domain into two parts: the first label and the rest of the domain.
parts := strings.SplitN(v, ".", 2)
// if there are less than 2 parts then this entry is malformed! There should
// at least be a "something." and a TLD like "com"
if len(parts) < 2 {
return fmt.Errorf(
"Malformed ExactBlockedNames entry, only one label: %q", v)
}
// Add the second part, the domain minus the first label, to the
// wildcardNameMap to block issuance for `*.`+parts[1]
wildcardNameMap[parts[1]] = true
}
pa.blocklistMu.Lock()
pa.blocklist = nameMap
pa.exactBlocklist = exactNameMap
pa.wildcardExactBlocklist = wildcardNameMap
pa.blocklistMu.Unlock()
return nil
}
// The values of maxDNSIdentifierLength, maxLabelLength and maxLabels are hard coded
// into the error messages errNameTooLong, errLabelTooLong and errTooManyLabels.
// If their values change, the related error messages should be updated.
const (
maxLabels = 10
// RFC 1034 says DNS labels have a max of 63 octets, and names have a max of 255
// octets: https://tools.ietf.org/html/rfc1035#page-10. Since two of those octets
// are taken up by the leading length byte and the trailing root period the actual
// max length becomes 253.
maxLabelLength = 63
maxDNSIdentifierLength = 253
)
var dnsLabelCharacterRegexp = regexp.MustCompile("^[a-z0-9-]+$")
func isDNSCharacter(ch byte) bool {
return ('a' <= ch && ch <= 'z') ||
('A' <= ch && ch <= 'Z') ||
('0' <= ch && ch <= '9') ||
ch == '.' || ch == '-'
}
// In these error messages:
// 253 is the value of maxDNSIdentifierLength
// 63 is the value of maxLabelLength
// 10 is the value of maxLabels
// If these values change, the related error messages should be updated.
var (
errNonPublic = berrors.MalformedError("Domain name does not end with a valid public suffix (TLD)")
errICANNTLD = berrors.MalformedError("Domain name is an ICANN TLD")
errPolicyForbidden = berrors.RejectedIdentifierError("The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy")
errInvalidDNSCharacter = berrors.MalformedError("Domain name contains an invalid character")
errNameTooLong = berrors.MalformedError("Domain name is longer than 253 bytes")
errIPAddressInDNS = berrors.MalformedError("Identifier type is DNS but value is an IP address")
errIPInvalid = berrors.MalformedError("IP address is invalid")
errIPSpecialPurpose = berrors.MalformedError("IP address is in a special-purpose address block")
errTooManyLabels = berrors.MalformedError("Domain name has more than 10 labels (parts)")
errEmptyIdentifier = berrors.MalformedError("Identifier value (name) is empty")
errNameEndsInDot = berrors.MalformedError("Domain name ends in a dot")
errTooFewLabels = berrors.MalformedError("Domain name needs at least one dot")
errLabelTooShort = berrors.MalformedError("Domain name can not have two dots in a row")
errLabelTooLong = berrors.MalformedError("Domain has a label (component between dots) longer than 63 bytes")
errMalformedIDN = berrors.MalformedError("Domain name contains malformed punycode")
errInvalidRLDH = berrors.RejectedIdentifierError("Domain name contains an invalid label in a reserved format (R-LDH: '??--')")
errTooManyWildcards = berrors.MalformedError("Domain name has more than one wildcard")
errMalformedWildcard = berrors.MalformedError("Domain name contains an invalid wildcard. A wildcard is only permitted before the first dot in a domain name")
errICANNTLDWildcard = berrors.MalformedError("Domain name is a wildcard for an ICANN TLD")
errWildcardNotSupported = berrors.MalformedError("Wildcard domain names are not supported")
errUnsupportedIdent = berrors.MalformedError("Invalid identifier type")
)
// validNonWildcardDomain checks that a domain isn't:
// - empty
// - prefixed with the wildcard label `*.`
// - made of invalid DNS characters
// - longer than the maxDNSIdentifierLength
// - an IPv4 or IPv6 address
// - suffixed with just "."
// - made of too many DNS labels
// - made of any invalid DNS labels
// - suffixed with something other than an IANA registered TLD
// - exactly equal to an IANA registered TLD
//
// It does NOT ensure that the domain is absent from any PA blocked lists.
func validNonWildcardDomain(domain string) error {
if domain == "" {
return errEmptyIdentifier
}
if strings.HasPrefix(domain, "*.") {
return errWildcardNotSupported
}
for _, ch := range []byte(domain) {
if !isDNSCharacter(ch) {
return errInvalidDNSCharacter
}
}
if len(domain) > maxDNSIdentifierLength {
return errNameTooLong
}
if ip := net.ParseIP(domain); ip != nil {
return errIPAddressInDNS
}
if strings.HasSuffix(domain, ".") {
return errNameEndsInDot
}
labels := strings.Split(domain, ".")
if len(labels) > maxLabels {
return errTooManyLabels
}
if len(labels) < 2 {
return errTooFewLabels
}
for _, label := range labels {
// Check that this is a valid LDH Label: "A string consisting of ASCII
// letters, digits, and the hyphen with the further restriction that the
// hyphen cannot appear at the beginning or end of the string. Like all DNS
// labels, its total length must not exceed 63 octets." (RFC 5890, 2.3.1)
if len(label) < 1 {
return errLabelTooShort
}
if len(label) > maxLabelLength {
return errLabelTooLong
}
if !dnsLabelCharacterRegexp.MatchString(label) {
return errInvalidDNSCharacter
}
if label[0] == '-' || label[len(label)-1] == '-' {
return errInvalidDNSCharacter
}
// Check if this is a Reserved LDH Label: "[has] the property that they
// contain "--" in the third and fourth characters but which otherwise
// conform to LDH label rules." (RFC 5890, 2.3.1)
if len(label) >= 4 && label[2:4] == "--" {
// Check if this is an XN-Label: "labels that begin with the prefix "xn--"
// (case independent), but otherwise conform to the rules for LDH labels."
// (RFC 5890, 2.3.1)
if label[0:2] != "xn" {
return errInvalidRLDH
}
// Check if this is a P-Label: "A XN-Label that contains valid output of
// the Punycode algorithm (as defined in RFC 3492, Section 6.3) from the
// fifth and subsequent positions." (Baseline Requirements, 1.6.1)
ulabel, err := idna.ToUnicode(label)
if err != nil {
return errMalformedIDN
}
if !norm.NFC.IsNormalString(ulabel) {
return errMalformedIDN
}
}
}
// Names must end in an ICANN TLD, but they must not be equal to an ICANN TLD.
icannTLD, err := iana.ExtractSuffix(domain)
if err != nil {
return errNonPublic
}
if icannTLD == domain {
return errICANNTLD
}
return nil
}
// ValidDomain checks that a domain is valid and that it doesn't contain any
// invalid wildcard characters. It does NOT ensure that the domain is absent
// from any PA blocked lists.
func ValidDomain(domain string) error {
if strings.Count(domain, "*") <= 0 {
return validNonWildcardDomain(domain)
}
// Names containing more than one wildcard are invalid.
if strings.Count(domain, "*") > 1 {
return errTooManyWildcards
}
// If the domain has a wildcard character, but it isn't the first most
// label of the domain name then the wildcard domain is malformed
if !strings.HasPrefix(domain, "*.") {
return errMalformedWildcard
}
// The base domain is the wildcard request with the `*.` prefix removed
baseDomain := strings.TrimPrefix(domain, "*.")
// Names must end in an ICANN TLD, but they must not be equal to an ICANN TLD.
icannTLD, err := iana.ExtractSuffix(baseDomain)
if err != nil {
return errNonPublic
}
// Names must have a non-wildcard label immediately adjacent to the ICANN
// TLD. No `*.com`!
if baseDomain == icannTLD {
return errICANNTLDWildcard
}
return validNonWildcardDomain(baseDomain)
}
// validIP checks that an IP address:
// - isn't empty
// - is an IPv4 or IPv6 address
// - isn't in an IANA special-purpose address registry
//
// It does NOT ensure that the IP address is absent from any PA blocked lists.
func validIP(ip string) error {
if ip == "" {
return errEmptyIdentifier
}
// Check the output of net.IP.String(), to ensure the input complied with
// RFC 8738, Sec. 3. ("The identifier value MUST contain the textual form of
// the address as defined in RFC 1123, Sec. 2.1 for IPv4 and in RFC 5952,
// Sec. 4 for IPv6.") ParseIP() will accept a non-compliant but otherwise
// valid string; String() will output a compliant string.
parsedIP := net.ParseIP(ip)
if parsedIP == nil || parsedIP.String() != ip {
return errIPInvalid
}
if bdns.IsReservedIP(parsedIP) {
return errIPSpecialPurpose
}
return nil
}
// forbiddenMailDomains is a map of domain names we do not allow after the
// @ symbol in contact mailto addresses. These are frequently used when
// copy-pasting example configurations and would not result in expiration
// messages and subscriber communications reaching the user that created the
// registration if allowed.
var forbiddenMailDomains = map[string]bool{
// https://tools.ietf.org/html/rfc2606#section-3
"example.com": true,
"example.net": true,
"example.org": true,
}
// ValidEmail returns an error if the input doesn't parse as an email address,
// the domain isn't a valid hostname in Preferred Name Syntax, or its on the
// list of domains forbidden for mail (because they are often used in examples).
func ValidEmail(address string) error {
email, err := mail.ParseAddress(address)
if err != nil {
return berrors.InvalidEmailError("unable to parse email address")
}
splitEmail := strings.SplitN(email.Address, "@", -1)
domain := strings.ToLower(splitEmail[len(splitEmail)-1])
err = validNonWildcardDomain(domain)
if err != nil {
return berrors.InvalidEmailError("contact email has invalid domain: %s", err)
}
if forbiddenMailDomains[domain] {
// We're okay including the domain in the error message here because this
// case occurs only for a small block-list of domains listed above.
return berrors.InvalidEmailError("contact email has forbidden domain %q", domain)
}
return nil
}
// subError returns an appropriately typed error based on the input error
func subError(ident identifier.ACMEIdentifier, err error) berrors.SubBoulderError {
var bErr *berrors.BoulderError
if errors.As(err, &bErr) {
return berrors.SubBoulderError{
Identifier: ident,
BoulderError: bErr,
}
} else {
return berrors.SubBoulderError{
Identifier: ident,
BoulderError: &berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: err.Error(),
},
}
}
}
// WillingToIssue determines whether the CA is willing to issue for the provided
// identifiers.
//
// It checks the criteria checked by `WellFormedIdentifiers`, and additionally
// checks whether any identifier is on a blocklist.
//
// If multiple identifiers are invalid, the error will contain suberrors
// specific to each identifier.
//
// Precondition: all input identifier values must be in lowercase.
func (pa *AuthorityImpl) WillingToIssue(idents identifier.ACMEIdentifiers) error {
err := WellFormedIdentifiers(idents)
if err != nil {
return err
}
var subErrors []berrors.SubBoulderError
for _, ident := range idents {
if !pa.IdentifierTypeEnabled(ident.Type) {
subErrors = append(subErrors, subError(ident, berrors.RejectedIdentifierError("The ACME server has disabled this identifier type")))
continue
}
// Only DNS identifiers are subject to wildcard and blocklist checks.
// Unsupported identifier types will have been caught by
// WellFormedIdentifiers().
//
// TODO(#7311): We may want to implement IP address blocklists too.
if ident.Type == identifier.TypeDNS {
if strings.Count(ident.Value, "*") > 0 {
// The base domain is the wildcard request with the `*.` prefix removed
baseDomain := strings.TrimPrefix(ident.Value, "*.")
// The base domain can't be in the wildcard exact blocklist
err = pa.checkWildcardHostList(baseDomain)
if err != nil {
subErrors = append(subErrors, subError(ident, err))
continue
}
}
// For both wildcard and non-wildcard domains, check whether any parent domain
// name is on the regular blocklist.
err := pa.checkHostLists(ident.Value)
if err != nil {
subErrors = append(subErrors, subError(ident, err))
continue
}
}
}
return combineSubErrors(subErrors)
}
// WellFormedIdentifiers returns an error if any of the provided identifiers do
// not meet these criteria:
//
// For DNS identifiers:
// - MUST contains only lowercase characters, numbers, hyphens, and dots
// - MUST NOT have more than maxLabels labels
// - MUST follow the DNS hostname syntax rules in RFC 1035 and RFC 2181
//
// In particular, DNS identifiers:
// - MUST NOT contain underscores
// - MUST NOT match the syntax of an IP address
// - MUST end in a public suffix
// - MUST have at least one label in addition to the public suffix
// - MUST NOT be a label-wise suffix match for a name on the block list,
// where comparison is case-independent (normalized to lower case)
//
// If a DNS identifier contains a *, we additionally require:
// - There is at most one `*` wildcard character
// - That the wildcard character is the leftmost label
// - That the wildcard label is not immediately adjacent to a top level ICANN
// TLD
//
// For IP identifiers:
// - MUST match the syntax of an IP address
// - MUST NOT be in an IANA special-purpose address registry
//
// If multiple identifiers are invalid, the error will contain suberrors
// specific to each identifier.
func WellFormedIdentifiers(idents identifier.ACMEIdentifiers) error {
var subErrors []berrors.SubBoulderError
for _, ident := range idents {
switch ident.Type {
case identifier.TypeDNS:
err := ValidDomain(ident.Value)
if err != nil {
subErrors = append(subErrors, subError(ident, err))
}
case identifier.TypeIP:
err := validIP(ident.Value)
if err != nil {
subErrors = append(subErrors, subError(ident, err))
}
default:
subErrors = append(subErrors, subError(ident, errUnsupportedIdent))
}
}
return combineSubErrors(subErrors)
}
func combineSubErrors(subErrors []berrors.SubBoulderError) error {
if len(subErrors) > 0 {
// If there was only one error, then use it as the top level error that is
// returned.
if len(subErrors) == 1 {
return berrors.RejectedIdentifierError(
"Cannot issue for %q: %s",
subErrors[0].Identifier.Value,
subErrors[0].BoulderError.Detail,
)
}
detail := fmt.Sprintf(
"Cannot issue for %q: %s (and %d more problems. Refer to sub-problems for more information.)",
subErrors[0].Identifier.Value,
subErrors[0].BoulderError.Detail,
len(subErrors)-1,
)
return (&berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: detail,
}).WithSubErrors(subErrors)
}
return nil
}
// checkWildcardHostList checks the wildcardExactBlocklist for a given domain.
// If the domain is not present on the list nil is returned, otherwise
// errPolicyForbidden is returned.
func (pa *AuthorityImpl) checkWildcardHostList(domain string) error {
pa.blocklistMu.RLock()
defer pa.blocklistMu.RUnlock()
if pa.wildcardExactBlocklist == nil {
return fmt.Errorf("Hostname policy not yet loaded.")
}
if pa.wildcardExactBlocklist[domain] {
return errPolicyForbidden
}
return nil
}
func (pa *AuthorityImpl) checkHostLists(domain string) error {
pa.blocklistMu.RLock()
defer pa.blocklistMu.RUnlock()
if pa.blocklist == nil {
return fmt.Errorf("Hostname policy not yet loaded.")
}
labels := strings.Split(domain, ".")
for i := range labels {
joined := strings.Join(labels[i:], ".")
if pa.blocklist[joined] {
return errPolicyForbidden
}
}
if pa.exactBlocklist[domain] {
return errPolicyForbidden
}
return nil
}
// ChallengeTypesFor determines which challenge types are acceptable for the
// given identifier. This determination is made purely based on the identifier,
// and not based on which challenge types are enabled, so that challenge type
// filtering can happen dynamically at request rather than being set in stone
// at creation time.
func (pa *AuthorityImpl) ChallengeTypesFor(ident identifier.ACMEIdentifier) ([]core.AcmeChallenge, error) {
switch ident.Type {
case identifier.TypeDNS:
// If the identifier is for a DNS wildcard name we only provide a DNS-01
// challenge, to comply with the BRs Sections 3.2.2.4.19 and 3.2.2.4.20
// stating that ACME HTTP-01 and TLS-ALPN-01 are not suitable for validating
// Wildcard Domains.
if strings.HasPrefix(ident.Value, "*.") {
return []core.AcmeChallenge{core.ChallengeTypeDNS01}, nil
}
// Return all challenge types we support for non-wildcard DNS identifiers.
return []core.AcmeChallenge{
core.ChallengeTypeHTTP01,
core.ChallengeTypeDNS01,
core.ChallengeTypeTLSALPN01,
}, nil
case identifier.TypeIP:
// Only HTTP-01 and TLS-ALPN-01 are suitable for IP address identifiers
// per RFC 8738, Sec. 4.
return []core.AcmeChallenge{
core.ChallengeTypeHTTP01,
core.ChallengeTypeTLSALPN01,
}, nil
default:
// Otherwise return an error because we don't support any challenges for this
// identifier type.
return nil, fmt.Errorf("unrecognized identifier type %q", ident.Type)
}
}
// ChallengeTypeEnabled returns whether the specified challenge type is enabled
func (pa *AuthorityImpl) ChallengeTypeEnabled(t core.AcmeChallenge) bool {
pa.blocklistMu.RLock()
defer pa.blocklistMu.RUnlock()
return pa.enabledChallenges[t]
}
// CheckAuthzChallenges determines that an authorization was fulfilled by a
// challenge that is currently enabled and was appropriate for the kind of
// identifier in the authorization.
func (pa *AuthorityImpl) CheckAuthzChallenges(authz *core.Authorization) error {
chall, err := authz.SolvedBy()
if err != nil {
return err
}
if !pa.ChallengeTypeEnabled(chall) {
return errors.New("authorization fulfilled by disabled challenge type")
}
challTypes, err := pa.ChallengeTypesFor(authz.Identifier)
if err != nil {
return err
}
if !slices.Contains(challTypes, chall) {
return errors.New("authorization fulfilled by inapplicable challenge type")
}
return nil
}
// IdentifierTypeEnabled returns whether the specified identifier type is enabled
func (pa *AuthorityImpl) IdentifierTypeEnabled(t identifier.IdentifierType) bool {
pa.blocklistMu.RLock()
defer pa.blocklistMu.RUnlock()
return pa.enabledIdentifiers[t]
}