boulder/policy/pa.go

624 lines
21 KiB
Go

package policy
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math/rand/v2"
"net"
"net/mail"
"os"
"regexp"
"slices"
"strings"
"sync"
"golang.org/x/net/idna"
"golang.org/x/text/unicode/norm"
"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
pseudoRNG *rand.Rand
rngMu sync.Mutex
}
// New constructs a Policy Authority.
func New(challengeTypes map[core.AcmeChallenge]bool, log blog.Logger) (*AuthorityImpl, error) {
pa := AuthorityImpl{
log: log,
enabledChallenges: challengeTypes,
// We don't need real randomness for this.
pseudoRNG: rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())),
}
return &pa, 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")
errIPAddress = berrors.MalformedError("The ACME server can not issue a certificate for an IP address")
errTooManyLabels = berrors.MalformedError("Domain name has more than 10 labels (parts)")
errEmptyName = berrors.MalformedError("Domain 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")
)
// 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 errEmptyName
}
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 errIPAddress
}
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)
}
// 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 {
if len(address) > 254 {
address = address[:254] + "..."
}
return berrors.InvalidEmailError("%q is not a valid e-mail address", 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 %q has invalid domain : %s",
email.Address, err)
}
if forbiddenMailDomains[domain] {
return berrors.InvalidEmailError(
"invalid contact domain. Contact emails @%s are forbidden",
domain)
}
return nil
}
// subError returns an appropriately typed error based on the input error
func subError(name string, err error) berrors.SubBoulderError {
var bErr *berrors.BoulderError
if errors.As(err, &bErr) {
return berrors.SubBoulderError{
Identifier: identifier.DNSIdentifier(name),
BoulderError: bErr,
}
} else {
return berrors.SubBoulderError{
Identifier: identifier.DNSIdentifier(name),
BoulderError: &berrors.BoulderError{
Type: berrors.RejectedIdentifier,
Detail: err.Error(),
},
}
}
}
// WillingToIssue determines whether the CA is willing to issue for the provided
// domain names.
//
// It checks the criteria checked by `WellFormedDomainNames`, and additionally checks
// whether any domain is on a blocklist.
//
// If multiple domains are invalid, the error will contain suberrors specific to
// each domain.
//
// Precondition: all input domain names must be in lowercase.
func (pa *AuthorityImpl) WillingToIssue(domains []string) error {
err := WellFormedDomainNames(domains)
if err != nil {
return err
}
var subErrors []berrors.SubBoulderError
for _, domain := range domains {
if strings.Count(domain, "*") > 0 {
// The base domain is the wildcard request with the `*.` prefix removed
baseDomain := strings.TrimPrefix(domain, "*.")
// The base domain can't be in the wildcard exact blocklist
err = pa.checkWildcardHostList(baseDomain)
if err != nil {
subErrors = append(subErrors, subError(domain, err))
continue
}
}
// For both wildcard and non-wildcard domains, check whether any parent domain
// name is on the regular blocklist.
err := pa.checkHostLists(domain)
if err != nil {
subErrors = append(subErrors, subError(domain, err))
continue
}
}
return combineSubErrors(subErrors)
}
// WellFormedDomainNames returns an error if any of the provided domains do not meet these criteria:
//
// - 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, it:
// - 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 domain 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
//
// If multiple domains are invalid, the error will contain suberrors specific to
// each domain.
func WellFormedDomainNames(domains []string) error {
var subErrors []berrors.SubBoulderError
for _, domain := range domains {
err := ValidDomain(domain)
if err != nil {
subErrors = append(subErrors, subError(domain, err))
}
}
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.
func (pa *AuthorityImpl) challengeTypesFor(identifier identifier.ACMEIdentifier) ([]core.AcmeChallenge, error) {
var challenges []core.AcmeChallenge
// If the identifier is for a DNS wildcard name we only
// provide a DNS-01 challenge as a matter of CA policy.
if strings.HasPrefix(identifier.Value, "*.") {
// We must have the DNS-01 challenge type enabled to create challenges for
// a wildcard identifier per LE policy.
if !pa.ChallengeTypeEnabled(core.ChallengeTypeDNS01) {
return nil, fmt.Errorf(
"Challenges requested for wildcard identifier but DNS-01 " +
"challenge type is not enabled")
}
// Only provide a DNS-01-Wildcard challenge
challenges = []core.AcmeChallenge{core.ChallengeTypeDNS01}
} else {
// Otherwise we collect up challenges based on what is enabled.
if pa.ChallengeTypeEnabled(core.ChallengeTypeHTTP01) {
challenges = append(challenges, core.ChallengeTypeHTTP01)
}
if pa.ChallengeTypeEnabled(core.ChallengeTypeTLSALPN01) {
challenges = append(challenges, core.ChallengeTypeTLSALPN01)
}
if pa.ChallengeTypeEnabled(core.ChallengeTypeDNS01) {
challenges = append(challenges, core.ChallengeTypeDNS01)
}
}
return challenges, nil
}
// ChallengesFor determines which challenge types are acceptable for the given
// identifier, and constructs new challenge objects for those challenge types.
// The resulting challenge objects all share a single challenge token and are
// returned in a random order.
func (pa *AuthorityImpl) ChallengesFor(identifier identifier.ACMEIdentifier) ([]core.Challenge, error) {
challTypes, err := pa.challengeTypesFor(identifier)
if err != nil {
return nil, err
}
challenges := make([]core.Challenge, len(challTypes))
token := core.NewToken()
for i, t := range challTypes {
c, err := core.NewChallenge(t, token)
if err != nil {
return nil, err
}
challenges[i] = c
}
// We shuffle the challenges to prevent ACME clients from relying on the
// specific order that boulder returns them in.
shuffled := make([]core.Challenge, len(challenges))
pa.rngMu.Lock()
defer pa.rngMu.Unlock()
for i, challIdx := range pa.pseudoRNG.Perm(len(challenges)) {
shuffled[i] = challenges[challIdx]
}
return shuffled, nil
}
// 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]
}
// CheckAuthz determines that an authorization was fulfilled by a challenge
// that was appropriate for the kind of identifier in the authorization.
func (pa *AuthorityImpl) CheckAuthz(authz *core.Authorization) error {
chall, err := authz.SolvedBy()
if err != nil {
return err
}
challTypes, err := pa.challengeTypesFor(authz.Identifier)
if err != nil {
return err
}
if !slices.Contains(challTypes, chall) {
return errors.New("authorization fulfilled by invalid challenge")
}
return nil
}