Skip to content

Commit

Permalink
Policy based issuance for wildcard identifiers (Round two) (#3252)
Browse files Browse the repository at this point in the history
This PR implements issuance for wildcard names in the V2 order flow. By policy, pending authorizations for wildcard names only receive a DNS-01 challenge for the base domain. We do not re-use authorizations for the base domain that do not come from a previous wildcard issuance (e.g. a normal authorization for example.com turned valid by way of a DNS-01 challenge will not be reused for a *.example.com order).

The wildcard prefix is stripped off of the authorization identifier value in two places:

When presenting the authorization to the user - ACME forbids having a wildcard character in an authorization identifier.
When performing validation - We validate the base domain name without the *. prefix.
This PR is largely a rewrite/extension of #3231. Instead of using a pseudo-challenge-type (DNS-01-Wildcard) to indicate an authorization & identifier correspond to the base name of a wildcard order name we instead allow the identifier to take the wildcard order name with the *. prefix.
  • Loading branch information
cpu authored and Roland Bracewell Shoemaker committed Dec 4, 2017
1 parent 55dd102 commit 1c99f91
Show file tree
Hide file tree
Showing 20 changed files with 746 additions and 53 deletions.
3 changes: 2 additions & 1 deletion core/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ type CertificateAuthority interface {
// PolicyAuthority defines the public interface for the Boulder PA
type PolicyAuthority interface {
WillingToIssue(domain AcmeIdentifier) error
ChallengesFor(domain AcmeIdentifier) (challenges []Challenge, validCombinations [][]int)
WillingToIssueWildcard(domain AcmeIdentifier) error
ChallengesFor(domain AcmeIdentifier) (challenges []Challenge, validCombinations [][]int, err error)
}

// StorageGetter are the Boulder SA's read-only methods
Expand Down
7 changes: 7 additions & 0 deletions core/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,13 @@ type Authorization struct {
// The server may suggest combinations of challenges if it
// requires more than one challenge to be completed.
Combinations [][]int `json:"combinations,omitempty" db:"combinations"`

// Wildcard is a Boulder-specific Authorization field that indicates the
// authorization was created as a result of an order containing a name with
// a `*.`wildcard prefix. This will help convey to users that an
// Authorization with the identifier `example.com` and one DNS-01 challenge
// corresponds to a name `*.example.com` from an associated order.
Wildcard bool `json:"wildcard,omitempty" db:"-"`
}

// FindChallenge will look for the given challenge inside this authorization. If
Expand Down
14 changes: 12 additions & 2 deletions csr/csr.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/goodkey"
)

Expand Down Expand Up @@ -75,10 +76,19 @@ func VerifyCSR(csr *x509.CertificateRequest, maxNames int, keyPolicy *goodkey.Ke
}
badNames := []string{}
for _, name := range csr.DNSNames {
if err := pa.WillingToIssue(core.AcmeIdentifier{
ident := core.AcmeIdentifier{
Type: core.IdentifierDNS,
Value: name,
}); err != nil {
}
var err error
// If wildcard names are enabled then use WillingToIssueWildcard
if features.Enabled(features.WildcardDomains) {
err = pa.WillingToIssueWildcard(ident)
} else {
// Otherwise use WillingToIssue
err = pa.WillingToIssue(ident)
}
if err != nil {
badNames = append(badNames, fmt.Sprintf("%q", name))
}
}
Expand Down
6 changes: 5 additions & 1 deletion csr/csr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var testingPolicy = &goodkey.KeyPolicy{

type mockPA struct{}

func (pa *mockPA) ChallengesFor(identifier core.AcmeIdentifier) (challenges []core.Challenge, combinations [][]int) {
func (pa *mockPA) ChallengesFor(identifier core.AcmeIdentifier) (challenges []core.Challenge, combinations [][]int, err error) {
return
}

Expand All @@ -34,6 +34,10 @@ func (pa *mockPA) WillingToIssue(id core.AcmeIdentifier) error {
return nil
}

func (pa *mockPA) WillingToIssueWildcard(id core.AcmeIdentifier) error {
return nil
}

func TestVerifyCSR(t *testing.T) {
private, err := rsa.GenerateKey(rand.Reader, 2048)
test.AssertNotError(t, err, "error generating test key")
Expand Down
4 changes: 2 additions & 2 deletions features/featureflag_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const (
RecheckCAA
UDPDNS
ROCACheck
// Allow issuance of wildcard domains for ACMEv2
WildcardDomains
)

// List of features and their default value, protected by fMu
Expand All @@ -48,6 +50,7 @@ var features = map[FeatureFlag]bool{
RecheckCAA: false,
UDPDNS: false,
ROCACheck: false,
WildcardDomains: false,
}

var fMu = new(sync.RWMutex)
Expand Down
102 changes: 86 additions & 16 deletions policy/pa.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ var (
errLabelTooLong = berrors.MalformedError("DNS label is too long")
errMalformedIDN = berrors.MalformedError("DNS label contains malformed punycode")
errInvalidRLDH = berrors.RejectedIdentifierError("DNS name contains a R-LDH label")
errTooManyWildcards = berrors.MalformedError("DNS name had more than one wildcard")
errMalformedWildcard = berrors.MalformedError("DNS name had a malformed wildcard label")
errICANNTLDWildcard = berrors.MalformedError("DNS name was a wildcard for an ICANN TLD")
)

// WillingToIssue determines whether the CA is willing to issue for the provided
Expand All @@ -150,7 +153,8 @@ var (
// * MUST NOT be a label-wise suffix match for a name on the black list,
// where comparison is case-independent (normalized to lower case)
//
// If WillingToIssue returns an error, it will be of type MalformedRequestError.
// If WillingToIssue returns an error, it will be of type MalformedRequestError
// or RejectedIdentifierError
func (pa *AuthorityImpl) WillingToIssue(id core.AcmeIdentifier) error {
if id.Type != core.IdentifierDNS {
return errInvalidIdentifier
Expand Down Expand Up @@ -236,6 +240,59 @@ func (pa *AuthorityImpl) WillingToIssue(id core.AcmeIdentifier) error {
return nil
}

// WillingToIssueWildcard is an extension of WillingToIssue that accepts DNS
// identifiers for well formed wildcard domains. It enforces that:
// * The identifer is a DNS type identifier
// * 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 all of the above is true then the base domain (e.g. without the *.) is run
// through WillingToIssue to catch other illegal things (blocked hosts, etc).
func (pa *AuthorityImpl) WillingToIssueWildcard(ident core.AcmeIdentifier) error {
// We're only willing to process DNS identifiers
if ident.Type != core.IdentifierDNS {
return errInvalidIdentifier
}
rawDomain := ident.Value

// If there is more than one wildcard in the domain the ident is invalid
if strings.Count(rawDomain, "*") > 1 {
return errTooManyWildcards
}

// If there is exactly one wildcard in the domain we need to do some special
// processing to ensure that it is a well formed wildcard request and to
// translate the identifer to its base domain for use with WillingToIssue
if strings.Count(rawDomain, "*") == 1 {
// If the rawDomain 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(rawDomain, "*.") {
return errMalformedWildcard
}
// The base domain is the wildcard request with the `*.` prefix removed
baseDomain := strings.TrimPrefix(rawDomain, "*.")
// Names must end in an ICANN TLD, but they must not be equal to an ICANN TLD.
icannTLD, err := extractDomainIANASuffix(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
}
// Check that the PA is willing to issue for the base domain
return pa.WillingToIssue(core.AcmeIdentifier{
Type: core.IdentifierDNS,
Value: baseDomain,
})
}

return pa.WillingToIssue(ident)
}

func (pa *AuthorityImpl) checkHostLists(domain string) error {
pa.blacklistMu.RLock()
defer pa.blacklistMu.RUnlock()
Expand All @@ -260,25 +317,38 @@ func (pa *AuthorityImpl) checkHostLists(domain string) error {

// ChallengesFor makes a decision of what challenges, and combinations, are
// acceptable for the given identifier.
//
// Note: Current implementation is static, but future versions may not be.
func (pa *AuthorityImpl) ChallengesFor(identifier core.AcmeIdentifier) ([]core.Challenge, [][]int) {
func (pa *AuthorityImpl) ChallengesFor(identifier core.AcmeIdentifier) ([]core.Challenge, [][]int, error) {
challenges := []core.Challenge{}

if pa.enabledChallenges[core.ChallengeTypeHTTP01] {
challenges = append(challenges, core.HTTPChallenge01())
}
// 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.enabledChallenges[core.ChallengeTypeDNS01] {
return nil, 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.Challenge{core.DNSChallenge01()}
} else {
// Otherwise we collect up challenges based on what is enabled.
if pa.enabledChallenges[core.ChallengeTypeHTTP01] {
challenges = append(challenges, core.HTTPChallenge01())
}

if pa.enabledChallenges[core.ChallengeTypeTLSSNI01] {
challenges = append(challenges, core.TLSSNIChallenge01())
}
if pa.enabledChallenges[core.ChallengeTypeTLSSNI01] {
challenges = append(challenges, core.TLSSNIChallenge01())
}

if features.Enabled(features.AllowTLS02Challenges) && pa.enabledChallenges[core.ChallengeTypeTLSSNI02] {
challenges = append(challenges, core.TLSSNIChallenge02())
}
if features.Enabled(features.AllowTLS02Challenges) && pa.enabledChallenges[core.ChallengeTypeTLSSNI02] {
challenges = append(challenges, core.TLSSNIChallenge02())
}

if pa.enabledChallenges[core.ChallengeTypeDNS01] {
challenges = append(challenges, core.DNSChallenge01())
if pa.enabledChallenges[core.ChallengeTypeDNS01] {
challenges = append(challenges, core.DNSChallenge01())
}
}

// We shuffle the challenges and combinations to prevent ACME clients from
Expand All @@ -298,7 +368,7 @@ func (pa *AuthorityImpl) ChallengesFor(identifier core.AcmeIdentifier) ([]core.C
shuffledCombos[i] = combinations[comboIdx]
}

return shuffled, shuffledCombos
return shuffled, shuffledCombos, nil
}

// ExtractDomainIANASuffix returns the public suffix of the domain using only the "ICANN"
Expand Down
116 changes: 115 additions & 1 deletion policy/pa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,80 @@ func TestWillingToIssue(t *testing.T) {
}
}

func TestWillingToIssueWildcard(t *testing.T) {
bannedDomains := []string{
`zombo.gov.us`,
}
pa := paImpl(t)

bannedBytes, err := json.Marshal(blacklistJSON{
Blacklist: bannedDomains,
})
test.AssertNotError(t, err, "Couldn't serialize banned list")
f, _ := ioutil.TempFile("", "test-wildcard-banlist.txt")
defer os.Remove(f.Name())
err = ioutil.WriteFile(f.Name(), bannedBytes, 0640)
test.AssertNotError(t, err, "Couldn't write serialized banned list to file")
err = pa.SetHostnamePolicyFile(f.Name())
test.AssertNotError(t, err, "Couldn't load policy contents from file")

makeDNSIdent := func(domain string) core.AcmeIdentifier {
return core.AcmeIdentifier{
Type: core.IdentifierDNS,
Value: domain,
}
}

testCases := []struct {
Name string
Ident core.AcmeIdentifier
ExpectedErr error
}{
{
Name: "Non-DNS identifier",
Ident: core.AcmeIdentifier{Type: "nickname", Value: "cpu"},
ExpectedErr: errInvalidIdentifier,
},
{
Name: "Too many wildcards",
Ident: makeDNSIdent("ok.*.whatever.*.example.com"),
ExpectedErr: errTooManyWildcards,
},
{
Name: "Misplaced wildcard",
Ident: makeDNSIdent("ok.*.whatever.example.com"),
ExpectedErr: errMalformedWildcard,
},
{
Name: "Missing ICANN TLD",
Ident: makeDNSIdent("*.ok.madeup"),
ExpectedErr: errNonPublic,
},
{
Name: "Wildcard for ICANN TLD",
Ident: makeDNSIdent("*.com"),
ExpectedErr: errICANNTLDWildcard,
},
{
Name: "Forbidden base domain",
Ident: makeDNSIdent("*.zombo.gov.us"),
ExpectedErr: errBlacklisted,
},
{
Name: "Valid wildcard domain",
Ident: makeDNSIdent("*.everything.is.possible.at.zombo.com"),
ExpectedErr: nil,
},
}

for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
result := pa.WillingToIssueWildcard(tc.Ident)
test.AssertEquals(t, result, tc.ExpectedErr)
})
}
}

var accountKeyJSON = `{
"kty":"RSA",
"n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ",
Expand All @@ -207,7 +281,8 @@ var accountKeyJSON = `{
func TestChallengesFor(t *testing.T) {
pa := paImpl(t)

challenges, combinations := pa.ChallengesFor(core.AcmeIdentifier{})
challenges, combinations, err := pa.ChallengesFor(core.AcmeIdentifier{})
test.AssertNotError(t, err, "ChallengesFor failed")

test.Assert(t, len(challenges) == len(enabledChallenges), "Wrong number of challenges returned")
test.Assert(t, len(combinations) == len(enabledChallenges), "Wrong number of combinations returned")
Expand All @@ -225,6 +300,45 @@ func TestChallengesFor(t *testing.T) {
test.AssertDeepEquals(t, expectedCombos, combinations)
}

func TestChallengesForWildcard(t *testing.T) {
// wildcardIdent is an identifier for a wildcard domain name
wildcardIdent := core.AcmeIdentifier{
Type: core.IdentifierDNS,
Value: "*.zombo.com",
}

mustConstructPA := func(t *testing.T, enabledChallenges map[string]bool) *AuthorityImpl {
pa, err := New(enabledChallenges)
test.AssertNotError(t, err, "Couldn't create policy implementation")
return pa
}

// First try to get a challenge for the wildcard ident without the
// DNS-01 challenge type enabled. This should produce an error
var enabledChallenges = map[string]bool{
core.ChallengeTypeHTTP01: true,
core.ChallengeTypeTLSSNI01: true,
core.ChallengeTypeDNS01: false,
}
pa := mustConstructPA(t, enabledChallenges)
_, _, err := pa.ChallengesFor(wildcardIdent)
test.AssertError(t, err, "ChallengesFor did not error for a wildcard ident "+
"when DNS-01 was disabled")
test.AssertEquals(t, err.Error(), "Challenges requested for wildcard "+
"identifier but DNS-01 challenge type is not enabled")

// Try again with DNS-01 enabled. It should not error and
// should return only one DNS-01 type challenge
enabledChallenges[core.ChallengeTypeDNS01] = true
pa = mustConstructPA(t, enabledChallenges)
challenges, combinations, err := pa.ChallengesFor(wildcardIdent)
test.AssertNotError(t, err, "ChallengesFor errored for a wildcard ident "+
"unexpectedly")
test.AssertEquals(t, len(combinations), 1)
test.AssertEquals(t, len(challenges), 1)
test.AssertEquals(t, challenges[0].Type, core.ChallengeTypeDNS01)
}

func TestExtractDomainIANASuffix_Valid(t *testing.T) {
testCases := []struct {
domain, want string
Expand Down

0 comments on commit 1c99f91

Please sign in to comment.