Implement legacy form of CAA (#3075)
This implements the pre-erratum 5065 version of CAA, behind a feature flag. This involved refactoring DNSClient.LookupCAA to return a list of CNAMEs in addition to the CAA records, and adding an alternate lookuper that does tree-climbing on single-depth aliases.
This commit is contained in:
parent
2fb247488f
commit
4266853092
20
bdns/dns.go
20
bdns/dns.go
|
|
@ -147,7 +147,7 @@ var (
|
|||
type DNSClient interface {
|
||||
LookupTXT(context.Context, string) (txts []string, authorities []string, err error)
|
||||
LookupHost(context.Context, string) ([]net.IP, error)
|
||||
LookupCAA(context.Context, string) ([]*dns.CAA, error)
|
||||
LookupCAA(context.Context, string) ([]*dns.CAA, []*dns.CNAME, error)
|
||||
LookupMX(context.Context, string) ([]string, error)
|
||||
}
|
||||
|
||||
|
|
@ -438,12 +438,13 @@ func (dnsClient *DNSClientImpl) LookupHost(ctx context.Context, hostname string)
|
|||
}
|
||||
|
||||
// LookupCAA sends a DNS query to find all CAA records associated with
|
||||
// the provided hostname.
|
||||
func (dnsClient *DNSClientImpl) LookupCAA(ctx context.Context, hostname string) ([]*dns.CAA, error) {
|
||||
// the provided hostname. It also returns all CNAMEs that were part of the
|
||||
// response, for using in CAA tree climbing.
|
||||
func (dnsClient *DNSClientImpl) LookupCAA(ctx context.Context, hostname string) ([]*dns.CAA, []*dns.CNAME, error) {
|
||||
dnsType := dns.TypeCAA
|
||||
r, err := dnsClient.exchangeOne(ctx, hostname, dnsType)
|
||||
if err != nil {
|
||||
return nil, &DNSError{dnsType, hostname, err, -1}
|
||||
return nil, nil, &DNSError{dnsType, hostname, err, -1}
|
||||
}
|
||||
|
||||
// If the resolver returns SERVFAIL for a certain list of FQDNs, return an
|
||||
|
|
@ -452,24 +453,27 @@ func (dnsClient *DNSClientImpl) LookupCAA(ctx context.Context, hostname string)
|
|||
// That is since fixed, but we have a handful of other domains that still return
|
||||
// SERVFAIL, but will need certificate renewals. After a suitable notice
|
||||
// period we will remove these exceptions.
|
||||
var CAAs []*dns.CAA
|
||||
if r.Rcode == dns.RcodeServerFailure {
|
||||
if dnsClient.caaSERVFAILExceptions == nil ||
|
||||
dnsClient.caaSERVFAILExceptions[hostname] {
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
} else {
|
||||
return nil, &DNSError{dnsType, hostname, nil, r.Rcode}
|
||||
return nil, nil, &DNSError{dnsType, hostname, nil, r.Rcode}
|
||||
}
|
||||
}
|
||||
|
||||
var CAAs []*dns.CAA
|
||||
var CNAMEs []*dns.CNAME
|
||||
for _, answer := range r.Answer {
|
||||
if answer.Header().Rrtype == dnsType {
|
||||
if caaR, ok := answer.(*dns.CAA); ok {
|
||||
CAAs = append(CAAs, caaR)
|
||||
} else if cnameR, ok := answer.(*dns.CNAME); ok {
|
||||
CNAMEs = append(CNAMEs, cnameR)
|
||||
}
|
||||
}
|
||||
}
|
||||
return CAAs, nil
|
||||
return CAAs, CNAMEs, nil
|
||||
}
|
||||
|
||||
// LookupMX sends a DNS query to find a MX record associated hostname and returns the
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ func TestDNSLookupsNoServer(t *testing.T) {
|
|||
_, err = obj.LookupHost(context.Background(), "letsencrypt.org")
|
||||
test.AssertError(t, err, "No servers")
|
||||
|
||||
_, err = obj.LookupCAA(context.Background(), "letsencrypt.org")
|
||||
_, _, err = obj.LookupCAA(context.Background(), "letsencrypt.org")
|
||||
test.AssertError(t, err, "No servers")
|
||||
}
|
||||
|
||||
|
|
@ -273,18 +273,18 @@ func TestDNSServFail(t *testing.T) {
|
|||
|
||||
// CAA lookup ignores validation failures from the resolver for now
|
||||
// and returns an empty list of CAA records.
|
||||
emptyCaa, err := obj.LookupCAA(context.Background(), bad)
|
||||
emptyCaa, _, err := obj.LookupCAA(context.Background(), bad)
|
||||
test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records")
|
||||
test.AssertNotError(t, err, "LookupCAA returned an error")
|
||||
|
||||
// When we turn on enforceCAASERVFAIL, such lookups should fail.
|
||||
obj.caaSERVFAILExceptions = map[string]bool{"servfailexception.example.com": true}
|
||||
emptyCaa, err = obj.LookupCAA(context.Background(), bad)
|
||||
emptyCaa, _, err = obj.LookupCAA(context.Background(), bad)
|
||||
test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records")
|
||||
test.AssertError(t, err, "LookupCAA should have returned an error")
|
||||
|
||||
// Unless they are on the exception list
|
||||
emptyCaa, err = obj.LookupCAA(context.Background(), "servfailexception.example.com")
|
||||
emptyCaa, _, err = obj.LookupCAA(context.Background(), "servfailexception.example.com")
|
||||
test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records")
|
||||
test.AssertNotError(t, err, "LookupCAA for servfail exception returned an error")
|
||||
}
|
||||
|
|
@ -390,15 +390,15 @@ func TestDNSNXDOMAIN(t *testing.T) {
|
|||
func TestDNSLookupCAA(t *testing.T) {
|
||||
obj := NewTestDNSClientImpl(time.Second*10, []string{dnsLoopbackAddr}, testStats, clock.NewFake(), 1)
|
||||
|
||||
caas, err := obj.LookupCAA(context.Background(), "bracewel.net")
|
||||
caas, _, err := obj.LookupCAA(context.Background(), "bracewel.net")
|
||||
test.AssertNotError(t, err, "CAA lookup failed")
|
||||
test.Assert(t, len(caas) > 0, "Should have CAA records")
|
||||
|
||||
caas, err = obj.LookupCAA(context.Background(), "nonexistent.letsencrypt.org")
|
||||
caas, _, err = obj.LookupCAA(context.Background(), "nonexistent.letsencrypt.org")
|
||||
test.AssertNotError(t, err, "CAA lookup failed")
|
||||
test.Assert(t, len(caas) == 0, "Shouldn't have CAA records")
|
||||
|
||||
caas, err = obj.LookupCAA(context.Background(), "cname.example.com")
|
||||
caas, _, err = obj.LookupCAA(context.Background(), "cname.example.com")
|
||||
test.AssertNotError(t, err, "CAA lookup failed")
|
||||
test.Assert(t, len(caas) > 0, "Should follow CNAME to find CAA")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,12 +90,61 @@ func (mock *MockDNSClient) LookupHost(_ context.Context, hostname string) ([]net
|
|||
}
|
||||
|
||||
// LookupCAA returns mock records for use in tests.
|
||||
func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, error) {
|
||||
func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, []*dns.CNAME, error) {
|
||||
var results []*dns.CAA
|
||||
var record dns.CAA
|
||||
switch strings.TrimRight(domain, ".") {
|
||||
case "caa-timeout.com":
|
||||
return nil, &DNSError{dns.TypeCAA, "always.timeout", MockTimeoutError(), -1}
|
||||
return nil, nil, &DNSError{dns.TypeCAA, "always.timeout", MockTimeoutError(), -1}
|
||||
case "deep-cname.not-present.com":
|
||||
cnameRecord := new(dns.CNAME)
|
||||
cnameRecord.Hdr = dns.RR_Header{Name: domain}
|
||||
cnameRecord.Target = "target.not-present.com"
|
||||
return nil, []*dns.CNAME{cnameRecord}, nil
|
||||
case "deep-cname.present-with-parameter.com":
|
||||
cnameRecord := new(dns.CNAME)
|
||||
cnameRecord.Hdr = dns.RR_Header{Name: domain}
|
||||
cnameRecord.Target = "cname-to-reserved.com"
|
||||
return []*dns.CAA{
|
||||
&dns.CAA{
|
||||
Tag: "issue",
|
||||
Value: "ca.com",
|
||||
},
|
||||
}, []*dns.CNAME{
|
||||
&dns.CNAME{
|
||||
Hdr: dns.RR_Header{Name: domain},
|
||||
Target: "cname-to-reserved.com",
|
||||
},
|
||||
&dns.CNAME{
|
||||
Hdr: dns.RR_Header{Name: "cname-to-reserved.com"},
|
||||
Target: "reserved.com",
|
||||
},
|
||||
}, nil
|
||||
case "blog.cname-to-subdomain.com":
|
||||
cnameRecord := new(dns.CNAME)
|
||||
cnameRecord.Hdr = dns.RR_Header{Name: domain}
|
||||
cnameRecord.Target = "www.blog.cname-to-subdomain.com"
|
||||
return nil, []*dns.CNAME{cnameRecord}, nil
|
||||
case "cname-to-reserved.com":
|
||||
cnameRecord := new(dns.CNAME)
|
||||
cnameRecord.Hdr = dns.RR_Header{Name: domain}
|
||||
cnameRecord.Target = "reserved.com"
|
||||
return []*dns.CAA{
|
||||
&dns.CAA{
|
||||
Tag: "issue",
|
||||
Value: "ca.com",
|
||||
},
|
||||
}, []*dns.CNAME{
|
||||
&dns.CNAME{
|
||||
Hdr: dns.RR_Header{Name: domain},
|
||||
Target: "reserved.com",
|
||||
},
|
||||
}, nil
|
||||
case "cname-to-child-of-reserved.com":
|
||||
cnameRecord := new(dns.CNAME)
|
||||
cnameRecord.Hdr = dns.RR_Header{Name: domain}
|
||||
cnameRecord.Target = "www.reserved.com"
|
||||
return nil, []*dns.CNAME{cnameRecord}, nil
|
||||
case "reserved.com":
|
||||
record.Tag = "issue"
|
||||
record.Value = "ca.com"
|
||||
|
|
@ -111,9 +160,9 @@ func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.C
|
|||
results = append(results, &record)
|
||||
case "com":
|
||||
// com has no CAA records.
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
case "servfail.com", "servfail.present.com":
|
||||
return results, fmt.Errorf("SERVFAIL")
|
||||
return results, nil, fmt.Errorf("SERVFAIL")
|
||||
case "multi-crit-present.com":
|
||||
record.Flag = 1
|
||||
record.Tag = "issue"
|
||||
|
|
@ -146,9 +195,9 @@ func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.C
|
|||
record.Value = ";"
|
||||
results = append(results, &record)
|
||||
case "bad-local-resolver.com":
|
||||
return nil, &DNSError{dns.TypeCAA, domain, MockTimeoutError(), -1}
|
||||
return nil, nil, &DNSError{dns.TypeCAA, domain, MockTimeoutError(), -1}
|
||||
}
|
||||
return results, nil
|
||||
return results, nil, nil
|
||||
}
|
||||
|
||||
// LookupMX is a mock
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ package features
|
|||
|
||||
import "fmt"
|
||||
|
||||
const _FeatureFlag_name = "unusedAllowAccountDeactivationAllowKeyRolloverResubmitMissingSCTsOnlyUseAIAIssuerURLAllowTLS02ChallengesGenerateOCSPEarlyReusePendingAuthzCountCertificatesExactRandomDirectoryEntryIPv6FirstDirectoryMetaAllowRenewalFirstRLRecheckCAA"
|
||||
const _FeatureFlag_name = "unusedAllowAccountDeactivationAllowKeyRolloverResubmitMissingSCTsOnlyUseAIAIssuerURLAllowTLS02ChallengesGenerateOCSPEarlyReusePendingAuthzCountCertificatesExactRandomDirectoryEntryIPv6FirstDirectoryMetaAllowRenewalFirstRLRecheckCAALegacyCAA"
|
||||
|
||||
var _FeatureFlag_index = [...]uint8{0, 6, 30, 46, 69, 84, 104, 121, 138, 160, 180, 189, 202, 221, 231}
|
||||
var _FeatureFlag_index = [...]uint8{0, 6, 30, 46, 69, 84, 104, 121, 138, 160, 180, 189, 202, 221, 231, 240}
|
||||
|
||||
func (i FeatureFlag) String() string {
|
||||
if i < 0 || i >= FeatureFlag(len(_FeatureFlag_index)-1) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const (
|
|||
DirectoryMeta
|
||||
AllowRenewalFirstRL
|
||||
RecheckCAA
|
||||
LegacyCAA
|
||||
)
|
||||
|
||||
// List of features and their default value, protected by fMu
|
||||
|
|
@ -45,6 +46,7 @@ var features = map[FeatureFlag]bool{
|
|||
DirectoryMeta: false,
|
||||
AllowRenewalFirstRL: false,
|
||||
RecheckCAA: false,
|
||||
LegacyCAA: false,
|
||||
}
|
||||
|
||||
var fMu = new(sync.RWMutex)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"ServerURL": "http://boulder:6000"
|
||||
},
|
||||
"features": {
|
||||
"LegacyCAA": true,
|
||||
"IPv6First": true
|
||||
},
|
||||
"remoteVAs": [
|
||||
|
|
|
|||
71
va/caa.go
71
va/caa.go
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
corepb "github.com/letsencrypt/boulder/core/proto"
|
||||
"github.com/letsencrypt/boulder/features"
|
||||
"github.com/letsencrypt/boulder/probs"
|
||||
vapb "github.com/letsencrypt/boulder/va/proto"
|
||||
"github.com/miekg/dns"
|
||||
|
|
@ -116,7 +117,65 @@ func parseResults(results []caaResult) (*CAASet, error) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string, lookuper func(context.Context, string) ([]*dns.CAA, error)) []caaResult {
|
||||
// parentsDomains returns a list of all parent domains, in order from longest to
|
||||
// shortest.
|
||||
func parentDomains(fqdn string) []string {
|
||||
var result []string
|
||||
labels := strings.Split(strings.TrimRight(fqdn, "."), ".")
|
||||
for i := 1; i < len(labels); i++ {
|
||||
result = append(result, strings.Join(labels[i:], "."))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Implement pre-erratum 5065 style tree-climbing CAA. Note: a strict
|
||||
// interpretation of pre-5065 indicates a linear lookup path - if there is any
|
||||
// CNAME at all, that precludes further tree-climbing on the original FQDN. This
|
||||
// is clearly wrong. We implement a hybrid approach that is strictly more
|
||||
// conservative: We always do full tree-climbing on the original FQDN (by virtue
|
||||
// of parallelCAALookup. When the LegacyCAA flag is enabled, we also
|
||||
// do linear tree climbing on single-level aliases.
|
||||
func (va *ValidationAuthorityImpl) treeClimbingLookupCAA(ctx context.Context, fqdn string) ([]*dns.CAA, error) {
|
||||
// We will do an (arbitrary) maximum of 15 tree-climbing queries to avoid CNAME/CAA
|
||||
// hybrid loops
|
||||
maxAttempts := 15
|
||||
return va.treeClimbingLookupCAAWithCount(ctx, fqdn, &maxAttempts)
|
||||
}
|
||||
|
||||
func (va *ValidationAuthorityImpl) treeClimbingLookupCAAWithCount(ctx context.Context, fqdn string, attemptsRemaining *int) ([]*dns.CAA, error) {
|
||||
if *attemptsRemaining < 1 {
|
||||
return nil, fmt.Errorf("too many CNAMEs when looking up CAA")
|
||||
}
|
||||
*attemptsRemaining--
|
||||
caas, cnames, err := va.dnsClient.LookupCAA(ctx, fqdn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(caas) > 0 {
|
||||
return caas, nil
|
||||
} else if len(cnames) > 0 {
|
||||
// CNAMEs are returned in order from the original fqdn to the ultimate
|
||||
// target. However, CAA wants us to check them in order from the ultimate
|
||||
// target back to the original FQDN.
|
||||
for i := len(cnames) - 1; i >= 0; i-- {
|
||||
// Start the tree climbing directly with the parent domains of each
|
||||
// target, because the target itself has already been queried by Unbound
|
||||
// as part of the original LookupCAA, and any CNAMEs are already in this
|
||||
// list.
|
||||
newTargets := parentDomains(cnames[i].Target)
|
||||
for _, newTarget := range newTargets {
|
||||
caas, err := va.treeClimbingLookupCAAWithCount(ctx, newTarget, attemptsRemaining)
|
||||
if len(caas) != 0 || err != nil {
|
||||
return caas, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type lookuperFunc func(context.Context, string) ([]*dns.CAA, error)
|
||||
|
||||
func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string, lookuper lookuperFunc) []caaResult {
|
||||
labels := strings.Split(name, ".")
|
||||
results := make([]caaResult, len(labels))
|
||||
var wg sync.WaitGroup
|
||||
|
|
@ -137,6 +196,14 @@ func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name s
|
|||
func (va *ValidationAuthorityImpl) getCAASet(ctx context.Context, hostname string) (*CAASet, error) {
|
||||
hostname = strings.TrimRight(hostname, ".")
|
||||
|
||||
lookuper := func(ctx context.Context, fqdn string) ([]*dns.CAA, error) {
|
||||
caas, _, err := va.dnsClient.LookupCAA(ctx, fqdn)
|
||||
return caas, err
|
||||
}
|
||||
if features.Enabled(features.LegacyCAA) {
|
||||
lookuper = va.treeClimbingLookupCAA
|
||||
}
|
||||
|
||||
// See RFC 6844 "Certification Authority Processing" for pseudocode.
|
||||
// Essentially: check CAA records for the FDQN to be issued, and all
|
||||
// parent domains.
|
||||
|
|
@ -145,7 +212,7 @@ func (va *ValidationAuthorityImpl) getCAASet(ctx context.Context, hostname strin
|
|||
// the RPC call.
|
||||
//
|
||||
// We depend on our resolver to snap CNAME and DNAME records.
|
||||
results := va.parallelCAALookup(ctx, hostname, va.dnsClient.LookupCAA)
|
||||
results := va.parallelCAALookup(ctx, hostname, lookuper)
|
||||
return parseResults(results)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,86 @@ import (
|
|||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/features"
|
||||
"github.com/letsencrypt/boulder/probs"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
)
|
||||
|
||||
func TestParentDomains(t *testing.T) {
|
||||
pd := parentDomains("")
|
||||
if len(pd) != 0 {
|
||||
t.Errorf("Incorrect result from parentDomains(%q): %s", "", pd)
|
||||
}
|
||||
pd = parentDomains("com")
|
||||
if len(pd) != 0 {
|
||||
t.Errorf("Incorrect result from parentDomains(%q): %s", "com", pd)
|
||||
}
|
||||
pd = parentDomains("blog.example.com")
|
||||
if len(pd) != 2 || pd[0] != "example.com" || pd[1] != "com" {
|
||||
t.Errorf("Incorrect result from parentDomains(%q): %s", "blog.example.com", pd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeClimbNotPresent(t *testing.T) {
|
||||
target := "deep-cname.not-present.com"
|
||||
_ = features.Set(map[string]bool{"LegacyCAA": true})
|
||||
va, _ := setup(nil, 0)
|
||||
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
|
||||
if prob != nil {
|
||||
t.Fatalf("Expected success for %q, got %s", target, prob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepTreeClimb(t *testing.T) {
|
||||
// The ultimate target of the CNAME has a CAA record preventing issuance, but
|
||||
// the parent of the FQDN has a CAA record permitting. The target of the CNAME
|
||||
// takes precedence.
|
||||
target := "deep-cname.present-with-parameter.com"
|
||||
_ = features.Set(map[string]bool{"LegacyCAA": true})
|
||||
va, _ := setup(nil, 0)
|
||||
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
|
||||
if prob == nil {
|
||||
t.Fatalf("Expected error for %q, got none", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeClimbingLookupCAASimpleSuccess(t *testing.T) {
|
||||
target := "www.present-with-parameter.com"
|
||||
_ = features.Set(map[string]bool{"LegacyCAA": true})
|
||||
va, _ := setup(nil, 0)
|
||||
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
|
||||
if prob != nil {
|
||||
t.Fatalf("Expected success for %q, got %s", target, prob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeClimbingLookupCAALimitHit(t *testing.T) {
|
||||
target := "blog.cname-to-subdomain.com"
|
||||
_ = features.Set(map[string]bool{"LegacyCAA": true})
|
||||
va, _ := setup(nil, 0)
|
||||
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
|
||||
if prob == nil {
|
||||
t.Fatalf("Expected failure for %q, got success", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCNAMEToReserved(t *testing.T) {
|
||||
target := "cname-to-reserved.com"
|
||||
_ = features.Set(map[string]bool{"LegacyCAA": true})
|
||||
va, _ := setup(nil, 0)
|
||||
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
|
||||
if prob == nil {
|
||||
t.Fatalf("Expected error for cname-to-reserved.com, got success")
|
||||
}
|
||||
if prob.Type != probs.ConnectionProblem {
|
||||
t.Errorf("Expected timeout error type %s, got %s", probs.ConnectionProblem, prob.Type)
|
||||
}
|
||||
expected := "CAA record for cname-to-reserved.com prevents issuance"
|
||||
if prob.Detail != expected {
|
||||
t.Errorf("checkCAA: got %#v, expected %#v", prob.Detail, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCAATimeout(t *testing.T) {
|
||||
va, _ := setup(nil, 0)
|
||||
err := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: "caa-timeout.com"})
|
||||
|
|
|
|||
Loading…
Reference in New Issue