diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52ccbc7..d675a5e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.20' + go-version: "1.21" - name: Build run: | go version @@ -46,7 +46,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: "1.23" - name: Other lint run: | go install golang.org/x/tools/cmd/goimports@latest diff --git a/README.md b/README.md index b11a2882..9c16685a 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ result verbosity levels: `short`, `normal` (default), `long`, and `trace`: Users can also include specific additional fields using the `--include-fields` flag and specifying a list of fields, e.g., `--include-fields=flags,resolver`. -Additional fields are: class, protocol, ttl, resolver, flags. +Additional fields are: class, protocol, ttl, resolver, flags, dnssec. Name Server Mode ---------------- diff --git a/go.mod b/go.mod index 60906a3b..80d90218 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/zmap/zdns -go 1.20 +go 1.21.1 require ( github.com/hashicorp/go-version v1.7.0 @@ -10,6 +10,7 @@ require ( github.com/schollz/progressbar/v3 v3.15.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 + github.com/zmap/go-dns-root-anchors v0.0.0-20241116225636-aa592d6ee59f github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837 github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 github.com/zmap/zflags v1.4.0-beta.1.0.20200204220219-9d95409821b6 diff --git a/go.sum b/go.sum index 063747b4..a88cb836 100644 --- a/go.sum +++ b/go.sum @@ -269,6 +269,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zmap/dns v1.1.63-zdns1 h1:vaZIXXLZqjmZTGcpu9qPDcunqWfxeRMh0OwLiCxcjsI= github.com/zmap/dns v1.1.63-zdns1/go.mod h1:L50pXblXGxDFLaon9W4vGSfC1rGIcBL29sS7sNvNKuI= +github.com/zmap/go-dns-root-anchors v0.0.0-20241116225636-aa592d6ee59f h1:XffIjsyncaK1BH7GpceohPRnesxBaO7lduwbXG05ICo= +github.com/zmap/go-dns-root-anchors v0.0.0-20241116225636-aa592d6ee59f/go.mod h1:AkYq/HWOOEFPx6YgVn2oYjaZjLw2R/HV5WUWfJrFQ6s= github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837 h1:DjHnADS2r2zynZ3WkCFAQ+PNYngMSNceRROi0pO6c3M= github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837/go.mod h1:9vp0bxqozzQwcjBwenEXfKVq8+mYbwHkQ1NF9Ap0DMw= github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= diff --git a/src/cli/cli.go b/src/cli/cli.go index 8f7fca7e..558a5c61 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -64,6 +64,7 @@ type QueryOptions struct { ClassString string `long:"class" default:"INET" description:"DNS class to query. Options: INET, CSNET, CHAOS, HESIOD, NONE, ANY."` ClientSubnetString string `long:"client-subnet" description:"Client subnet in CIDR format for EDNS0."` Dnssec bool `long:"dnssec" description:"Requests DNSSEC records by setting the DNSSEC OK (DO) bit"` + ValidateDNSSEC bool `long:"validate-dnssec" description:"Validate DNSSEC records, only applicable with --iterative"` UseNSID bool `long:"nsid" description:"Request NSID."` } @@ -90,7 +91,7 @@ type InputOutputOptions struct { BlacklistFilePath string `long:"blacklist-file" description:"blacklist file for servers to exclude from lookups"` DNSConfigFilePath string `long:"conf-file" default:"/etc/resolv.conf" description:"config file for DNS servers"` MultipleModuleConfigFilePath string `short:"c" long:"multi-config-file" description:"config file path for multiple module"` - IncludeInOutput string `long:"include-fields" description:"Comma separated list of fields to additionally output beyond result verbosity. Options: class, protocol, ttl, resolver, flags"` + IncludeInOutput string `long:"include-fields" description:"Comma separated list of fields to additionally output beyond result verbosity. Options: class, protocol, ttl, resolver, flags, dnssec"` InputFilePath string `short:"f" long:"input-file" default:"-" description:"names to read, defaults to stdin"` LogFilePath string `long:"log-file" default:"-" description:"where should JSON logs be saved, defaults to stderr"` MetadataFilePath string `long:"metadata-file" description:"where should JSON metadata be saved, defaults to no metadata output. Use '-' for stderr."` diff --git a/src/cli/worker_manager.go b/src/cli/worker_manager.go index cd20cac1..bd8a61ba 100644 --- a/src/cli/worker_manager.go +++ b/src/cli/worker_manager.go @@ -211,7 +211,17 @@ func populateResolverConfig(gc *CLIConf) *zdns.ResolverConfig { config.MaxDepth = gc.MaxDepth config.CheckingDisabledBit = gc.CheckingDisabled config.ShouldRecycleSockets = !gc.DisableRecycleSockets - config.DNSSecEnabled = gc.Dnssec + + config.ShouldValidateDNSSEC = gc.ValidateDNSSEC + if config.ShouldValidateDNSSEC { + config.DNSSecEnabled = true + if !gc.IterativeResolution { + log.Fatal("DNSSEC validation is only supported with iterative resolution") + } + } else { + config.DNSSecEnabled = gc.Dnssec + } + config.DNSConfigFilePath = gc.DNSConfigFilePath config.LogLevel = log.Level(gc.Verbosity) diff --git a/src/zdns/answers.go b/src/zdns/answers.go index f85c0dd1..23c92744 100644 --- a/src/zdns/answers.go +++ b/src/zdns/answers.go @@ -78,6 +78,21 @@ type DNSKEYAnswer struct { PublicKey string `json:"public_key" groups:"short,normal,long,trace"` } +func (r *DNSKEYAnswer) ToVanillaType() *dns.DNSKEY { + return &dns.DNSKEY{ + Hdr: dns.RR_Header{ + Name: dns.CanonicalName(r.Name), + Rrtype: r.RrType, + Class: dns.StringToClass[r.Class], + Ttl: r.TTL, + }, + Flags: r.Flags, + Protocol: r.Protocol, + Algorithm: r.Algorithm, + PublicKey: r.PublicKey, + } +} + type DSAnswer struct { Answer KeyTag uint16 `json:"key_tag" groups:"short,normal,long,trace"` @@ -86,6 +101,22 @@ type DSAnswer struct { Digest string `json:"digest" groups:"short,normal,long,trace"` } +func (r *DSAnswer) ToVanillaType() *dns.DS { + return &dns.DS{ + Hdr: dns.RR_Header{ + + Name: dns.CanonicalName(r.Name), + Rrtype: r.RrType, + Class: dns.StringToClass[r.Class], + Ttl: r.TTL, + }, + KeyTag: r.KeyTag, + Algorithm: r.Algorithm, + DigestType: r.DigestType, + Digest: r.Digest, + } +} + type GPOSAnswer struct { Answer Longitude string `json:"preference" groups:"short,normal,long,trace"` @@ -186,6 +217,36 @@ type RRSIGAnswer struct { Signature string `json:"signature" groups:"short,normal,long,trace"` } +func (r *RRSIGAnswer) ToVanillaType() *dns.RRSIG { + expiration, err := dns.StringToTime(r.Expiration) + if err != nil { + panic("failed to parse expiration time: " + r.Expiration) + } + + inception, err := dns.StringToTime(r.Inception) + if err != nil { + panic("failed to parse inception time: " + r.Inception) + } + + return &dns.RRSIG{ + Hdr: dns.RR_Header{ + Name: dns.CanonicalName(r.Name), + Rrtype: r.RrType, + Class: dns.StringToClass[r.Class], + Ttl: r.TTL, + }, + TypeCovered: r.TypeCovered, + Algorithm: r.Algorithm, + Labels: r.Labels, + OrigTtl: r.OriginalTTL, + Expiration: expiration, + Inception: inception, + KeyTag: r.KeyTag, + SignerName: r.SignerName, + Signature: r.Signature, + } +} + type RPAnswer struct { Answer Mbox string `json:"mbox" groups:"short,normal,long,trace"` diff --git a/src/zdns/cache.go b/src/zdns/cache.go index d88fca54..728c0dc6 100644 --- a/src/zdns/cache.go +++ b/src/zdns/cache.go @@ -309,7 +309,38 @@ func (s *Cache) SafeAddCachedAuthority(res *SingleQueryResult, ns *NameServer, d nsString = ns.String() } - cachedRes := s.buildCachedResult(res, depth, layer) + // Referrals may contain DS records in the authority section. These need to be cached under the child name. + delegateToDSRRs := make(map[string][]interface{}) + var otherRRs []interface{} + for _, rr := range res.Authorities { + if dsRR, ok := rr.(DSAnswer); ok { + delegateName := removeTrailingDotIfNotRoot(dsRR.BaseAns().Name) + delegateToDSRRs[delegateName] = append(delegateToDSRRs[delegateName], dsRR) + } else { + otherRRs = append(otherRRs, rr) + } + } + + if len(delegateToDSRRs) > 0 { + s.VerboseLog(depth+1, "SafeAddCachedAuthority: found DS records in authority section, caching under child names") + + for delegateName, dsRRs := range delegateToDSRRs { + dsRes := &SingleQueryResult{ + Answers: dsRRs, + Protocol: res.Protocol, + Resolver: res.Resolver, + Flags: res.Flags, + TLSServerHandshake: res.TLSServerHandshake, + } + dsRes.Flags.Authoritative = true + dsCachedRes := s.buildCachedResult(dsRes, depth, layer) + s.addCachedAnswer(Question{Name: delegateName, Type: dns.TypeDS, Class: dns.ClassINET}, nsString, false, dsCachedRes, depth) + } + } + + copiedRes := *res + copiedRes.Authorities = otherRRs + cachedRes := s.buildCachedResult(&copiedRes, depth, layer) if len(cachedRes.Answers) == 0 && len(cachedRes.Authorities) == 0 && len(cachedRes.Additionals) == 0 { s.VerboseLog(depth+1, "SafeAddCachedAnswer: no cacheable records found, aborting") return diff --git a/src/zdns/conf.go b/src/zdns/conf.go index 5dff5c7e..89e0487e 100644 --- a/src/zdns/conf.go +++ b/src/zdns/conf.go @@ -49,8 +49,17 @@ const ( StatusIterTimeout Status = "ITERATIVE_TIMEOUT" StatusNoAuth Status = "NOAUTH" StatusNoNeededGlue Status = "NONEEDEDGLUE" // When a nameserver is authoritative for itself and the parent nameserver doesn't provide the glue to look it up + StatusCircular Status = "CIRCULAR" // When circular query dependencies are detected ) +func isStatusRetryable(status Status) bool { + switch status { + case StatusServFail, StatusNXDomain, StatusRefused, StatusTruncated, StatusError, StatusTimeout, StatusIterTimeout: + return true + } + return false +} + var RootServersV4 = []NameServer{ {IP: net.ParseIP("198.41.0.4"), Port: 53}, // A {IP: net.ParseIP("170.247.170.2"), Port: 53}, // B - Changed several times, this is current as of July '24 diff --git a/src/zdns/dnssec.go b/src/zdns/dnssec.go new file mode 100644 index 00000000..725df81f --- /dev/null +++ b/src/zdns/dnssec.go @@ -0,0 +1,458 @@ +/* + * ZDNS Copyright 2024 Regents of the University of Michigan + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package zdns + +import ( + "fmt" + "strings" + "time" + + "github.com/miekg/dns" + "github.com/pkg/errors" + rootanchors "github.com/zmap/go-dns-root-anchors" +) + +const rootZone = "." +const ( + zoneSigningKeyFlag = 256 + keySigningKeyFlag = 257 +) + +// validate performs DNSSEC validation for all sections of a DNS message. +// It validates the Answer, Additional, and Authority sections independently, +// collects all encountered DS and DNSKEY records, and determines the overall +// DNSSEC status. +// +// Parameters: +// - depth: Current recursion depth for logging purposes +// - trace: Trace context for tracking validation path +// +// Returns: +// - *DNSSECResult: Contains validation results for all message sections: +// - Status: Overall DNSSEC validation status (Secure/Insecure/Bogus/Indeterminate) +// - DS: Collection of DS records actually used during validation +// - DNSKEY: Collection of DNSKEY records actually used during validation +// - Answer/Additionals/Authoritative: Per-RRset validation results +// +// - Trace: Updated trace context containing validation path +func (v *dNSSECValidator) validate(depth int, trace Trace) (*DNSSECResult, Trace) { + result := makeDNSSECResult() + + if !hasRRSIG(v.msg) { + v.r.verboseLog(depth, "DNSSEC: No RRSIG records found in message") + result.Status = DNSSECInsecure // This can't be secure, but it could be bogus instead + return result, trace + } + + // Validate the answer section + sectionRes, trace := v.validateSection(v.msg.Answer, depth, trace) + result.Answer = sectionRes + + // Validate the additional section + sectionRes, trace = v.validateSection(v.msg.Extra, depth, trace) + result.Additionals = sectionRes + + // Validate the authoritative section + sectionRes, trace = v.validateSection(v.msg.Ns, depth, trace) + result.Authoritative = sectionRes + + for ds := range v.ds { + parsed := ParseAnswer(&ds).(DSAnswer) //nolint:golint,errcheck + result.DS = append(result.DS, &parsed) + } + for dnskey := range v.dNSKEY { + parsed := ParseAnswer(&dnskey).(DNSKEYAnswer) //nolint:golint,errcheck + result.DNSKEY = append(result.DNSKEY, &parsed) + } + + result.populateStatus() + + return result, trace +} + +// validateSection validates DNSSEC records for a given DNS message section. +// +// Parameters: +// - section: DNS message section containing RRs to validate +// - depth: Current recursion depth for logging +// - trace: Trace context for tracking request path +// +// Returns: +// - []DNSSECPerSetResult: Results of DNSSEC validation per RRset +// - Trace: Updated trace context +func (v *dNSSECValidator) validateSection(section []dns.RR, depth int, trace Trace) ([]DNSSECPerSetResult, Trace) { + typeToRRSets, typeToRRSigs := splitRRsetsAndSigs(section) + result := make([]DNSSECPerSetResult, 0) + + // Verify if for each RRset there is a corresponding RRSIG + for rrsKey, rrSet := range typeToRRSets { + setResult := DNSSECPerSetResult{ + RRset: rrsKey, + Status: DNSSECIndeterminate, + } + + rrsigs, ok := typeToRRSigs[rrsKey] + if !ok { + setResult.Status = DNSSECInsecure + } else { + v.r.verboseLog(depth, "DNSSEC: Verifying RRSIGs for RRset", rrsKey.String()) + + // Validate the RRSIGs for the RRset using validateRRSIG + sigUsed, updatedTrace, err := v.validateRRSIG(rrsKey.Type, rrSet, rrsigs, trace, depth+1) + trace = updatedTrace + if sigUsed != nil { + setResult.Status = DNSSECSecure + + sigParsed := ParseAnswer(sigUsed).(RRSIGAnswer) //nolint:golint,errcheck + setResult.Signature = &sigParsed + } else { + v.r.verboseLog(depth+1, "could not verify any RRSIG for RRset", rrsKey.String(), "err:", err) + // TODO: This check for bogus is not comprehensive or entirely accurate. + // If the error is due to the inability to retrieve DNSKEY or DS records, the status should be indeterminate. + // If a DS record exists at the SOA, but no RRSIG is found here, the status should be bogus (this case is not handled here). + // If no DS record is found at the SOA, the status should be insecure because a chain of trust cannot be established. + // However, this is unlikely in this context because an RRSIG should not exist without a corresponding DS record, + // unless the domain starts a different trust anchor (which most resolvers would not trust anyway). + // Distinguishing between these cases requires additional context, which would involve storing or querying more information + // about the domain. These operations can be costly, so we need to decide if the additional complexity is worth it. + setResult.Status = DNSSECBogus + setResult.Error = err.Error() + } + } + + result = append(result, setResult) + } + + return result, trace +} + +// hasRRSIG checks if any RRSIG records exist in any section of a DNS message. +func hasRRSIG(msg *dns.Msg) bool { + // Check Answer section + for _, rr := range msg.Answer { + if _, ok := rr.(*dns.RRSIG); ok { + return true + } + } + + // Check Authority section + for _, rr := range msg.Ns { + if _, ok := rr.(*dns.RRSIG); ok { + return true + } + } + + // Check Additional section + for _, rr := range msg.Extra { + if _, ok := rr.(*dns.RRSIG); ok { + return true + } + } + + return false +} + +// splitRRsetsAndSigs separates DNS resource records into RRsets and their corresponding RRSIGs. +// +// Parameters: +// - rrs: Slice of DNS resource records to split +// +// Returns: +// - map[RRsetKey][]dns.RR: Map of RRset keys to their resource records +// - map[RRsetKey][]*dns.RRSIG: Map of RRset keys to their RRSIG records +func splitRRsetsAndSigs(rrs []dns.RR) (map[RRsetKey][]dns.RR, map[RRsetKey][]*dns.RRSIG) { + typeToRRSets := make(map[RRsetKey][]dns.RR) + typeToRRSigs := make(map[RRsetKey][]*dns.RRSIG) + + for _, rr := range rrs { + rrsKey := RRsetKey{ + Name: rr.Header().Name, + Class: rr.Header().Class, + } + switch rr := rr.(type) { + case *dns.RRSIG: + rrsKey.Type = rr.TypeCovered + typeToRRSigs[rrsKey] = append(typeToRRSigs[rrsKey], rr) + default: + rrsKey.Type = rr.Header().Rrtype + typeToRRSets[rrsKey] = append(typeToRRSets[rrsKey], rr) + } + } + + return typeToRRSets, typeToRRSigs +} + +// findSEPsFromAnswer extracts SEP keys from a DNSKEY RRset answer. +// +// Parameters: +// - rrSet: The DNSKEY RRset to parse +// +// Returns: +// - map[uint16]*dns.DNSKEY: Map of KeyTag to SEP key records +// - error: Error if invalid records are found or no SEP present +func (v *dNSSECValidator) findSEPsFromAnswer(rrSet []dns.RR, signerDomain string, depth int, trace Trace) (map[uint16]*dns.DNSKEY, Trace, error) { + dnskeys := make(map[uint16]*dns.DNSKEY) + + for _, rr := range rrSet { + dnskey, ok := rr.(*dns.DNSKEY) + if !ok { + return nil, trace, fmt.Errorf("invalid RR type in DNSKEY RRset: %v", rr) + } + + switch dnskey.Flags { + case keySigningKeyFlag, zoneSigningKeyFlag: + dnskeys[dnskey.KeyTag()] = dnskey + default: + return nil, trace, fmt.Errorf("unexpected DNSKEY flag: %d", dnskey.Flags) + } + } + + if len(dnskeys) == 0 { + return nil, trace, errors.New("could not find any DNSKEY") + } + + // Find SEP keys + sepKeys, trace, err := v.findSEPs(signerDomain, dnskeys, trace, depth) + if err != nil { + return nil, trace, err + } + + return sepKeys, nil, nil +} + +// getDNSKEYs retrieves and validates DNSKEY records from the signer domain. +// +// Parameters: +// - signerDomain: Domain name to query for DNSKEY records +// - trace: Trace context +// - depth: Current recursion depth for logging +// +// Returns: +// - map[uint16]*dns.DNSKEY: Map of KeyTag to SEP DNSKEY records +// - map[uint16]*dns.DNSKEY: Map of KeyTag to DNSKEY records +// - Trace: Updated trace context +// - error: Error if DNSKEY retrieval or validation fails +func (v *dNSSECValidator) getDNSKEYs(signerDomain string, trace Trace, depth int) (map[uint16]*dns.DNSKEY, map[uint16]*dns.DNSKEY, Trace, error) { + dnskeys := make(map[uint16]*dns.DNSKEY) + + nameWithoutTrailingDot := removeTrailingDotIfNotRoot(signerDomain) + if signerDomain == rootZone { + nameWithoutTrailingDot = rootZone + } + + dnskeyQuestion := QuestionWithMetadata{ + Q: Question{ + Name: nameWithoutTrailingDot, + Type: dns.TypeDNSKEY, + Class: dns.ClassINET, + }, + RetriesRemaining: &v.r.retriesRemaining, + } + + res, trace, status, err := v.r.lookup(v.ctx, &dnskeyQuestion, v.r.rootNameServers, v.isIterative, trace) + // DNSSECResult may be nil if the response is from the cache. + if status != StatusNoError || err != nil || (res.DNSSECResult != nil && res.DNSSECResult.Status != DNSSECSecure) { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Failed to get DNSKEYs for signer domain %s, query status: %s, err: %v", signerDomain, status, err)) + return nil, nil, trace, fmt.Errorf("cannot get DNSKEYs for signer domain %s", signerDomain) + } + + // RRSIGs of res should have been verified before returning to here. + + // Construct key tag to DNSKEY map + for _, rr := range res.Answers { + zTypedKey, ok := rr.(DNSKEYAnswer) + if !ok { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Non-DNSKEY RR type in DNSKEY answer: %v", rr)) + continue + } + dnskey := zTypedKey.ToVanillaType() + + switch dnskey.Flags { + case keySigningKeyFlag, zoneSigningKeyFlag: + dnskeys[dnskey.KeyTag()] = dnskey + default: + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Unexpected DNSKEY flag %d in DNSKEY answer", dnskey.Flags)) + } + } + + // Error if no DNSKEY is found + if len(dnskeys) == 0 { + return nil, nil, trace, errors.New("missing at least one DNSKEY answer") + } + + // Find SEP keys + // Don't actually need to because this have must been checked during the lookup for DNSKEY records. + // Keeping this here only so we can include matched DS records in the output. + var sepKeys map[uint16]*dns.DNSKEY + sepKeys, trace, err = v.findSEPs(signerDomain, dnskeys, trace, depth) + if err != nil { + return nil, nil, trace, err + } + + return sepKeys, dnskeys, trace, nil +} + +// findSEPs validates DS records against DNSKEY records, +// to find the SEP (Secure Entry Point) keys for a given signer domain. +// +// Parameters: +// - signerDomain: The signer domain to query for DS records +// - dnskeyMap: A map of KeyTag to DNSKEYs to search for SEP keys +// - trace: The trace context for tracking request path +// - depth: The recursion depth for logging purposes +// +// Returns: +// - map[uint16]*dns.DNSKEY: Map of KeyTag to SEP DNSKEY records +// - Trace: Updated trace context +// - error: If validation fails for any DS record +func (v *dNSSECValidator) findSEPs(signerDomain string, dnskeyMap map[uint16]*dns.DNSKEY, trace Trace, depth int) (map[uint16]*dns.DNSKEY, Trace, error) { + nameWithoutTrailingDot := removeTrailingDotIfNotRoot(signerDomain) + + dsQuestion := QuestionWithMetadata{ + Q: Question{ + Name: nameWithoutTrailingDot, + Type: dns.TypeDS, + Class: dns.ClassINET, + }, + RetriesRemaining: &v.r.retriesRemaining, + } + + dsRecords := make(map[uint16]dns.DS) + if signerDomain == rootZone { + // Root zone, use the root anchors + dsRecords = rootanchors.GetValidDSRecords() + } else { + // DNSSECResult may be nil if the response is from the cache. + res, newTrace, status, err := v.r.lookup(v.ctx, &dsQuestion, v.r.rootNameServers, v.isIterative, trace) + trace = newTrace + if status != StatusNoError || err != nil || (res.DNSSECResult != nil && res.DNSSECResult.Status != DNSSECSecure) { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Failed to get DS records for signer domain %s, query status: %s, err: %v", signerDomain, status, err)) + return nil, trace, fmt.Errorf("failed to get DS records for signer domain %s", signerDomain) + } + + // RRSIGs of res should have been verified before returning to here. + + for _, rr := range res.Answers { + zTypedDS, ok := rr.(DSAnswer) + if !ok { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Non-DS RR type in DS answer: %v", rr)) + continue + } + ds := zTypedDS.ToVanillaType() + dsRecords[ds.KeyTag] = *ds + } + } + + sepKeys := make(map[uint16]*dns.DNSKEY) + for _, key := range dnskeyMap { + authenticDS, ok := dsRecords[key.KeyTag()] + if !ok { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: No DS record found for DNSKEY with KeyTag %d", key.KeyTag())) + continue + } + + actualDS := key.ToDS(authenticDS.DigestType) + if actualDS == nil { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Failed to convert DNSKEY with KeyTag %d to DS record", key.KeyTag())) + continue + } + + actualDigest := strings.ToUpper(actualDS.Digest) + authenticDigest := strings.ToUpper(authenticDS.Digest) + if actualDigest != authenticDigest { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: DS record mismatch for DNSKEY with KeyTag %d: expected %s, got %s", key.KeyTag(), authenticDigest, actualDigest)) + } else { + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: Delegation verified for DNSKEY with KeyTag %d, SEP established", key.KeyTag())) + + v.ds[*actualDS] = true + sepKeys[key.KeyTag()] = key + } + } + + if len(sepKeys) == 0 { + return nil, trace, errors.New("no SEP matching DS found") + } + + return sepKeys, trace, nil +} + +// validateRRSIG verifies RRSIGs for a given RRset using appropriate DNSKEYs. +// For DNSKEY RRsets, SEPs from the answer are used. For other types, +// ZSKs are retrieved from the signer domain. +// +// Parameters: +// - rrSetType: Type of records being validated +// - rrSet: Set of records to validate +// - rrsigs: RRSIG records to verify +// - trace: Trace context +// - depth: Current recursion depth for logging +// +// Returns: +// - *dns.RRSIG: First successfully validated RRSIG, or nil if none +// - Trace: Updated trace context +// - error: Error if no RRSIG could be validated +func (v *dNSSECValidator) validateRRSIG(rrSetType uint16, rrSet []dns.RR, rrsigs []*dns.RRSIG, trace Trace, depth int) (*dns.RRSIG, Trace, error) { + var dnskeyMap map[uint16]*dns.DNSKEY + var err error + + // Attempt to verify each RRSIG using only the DNSKEY matching its KeyTag + lastErr := errors.New("no RRSIG to verify") + for _, rrsig := range rrsigs { + // If RRset type is DNSKEY, use SEPs found from the answer directly + if rrSetType == dns.TypeDNSKEY { + dnskeyMap, trace, err = v.findSEPsFromAnswer(rrSet, rrsig.SignerName, depth, trace) + if err != nil { + return nil, trace, err + } + } else { + // For other RRset types, fetch DNSKEYs for each RRSIG's signer domain + v.r.verboseLog(depth, "DNSSEC: Verifying RRSIG with signer", rrsig.SignerName) + + _, zskMap, updatedTrace, err := v.getDNSKEYs(rrsig.SignerName, trace, depth+1) + dnskeyMap = zskMap + if err != nil { + lastErr = fmt.Errorf("failed to retrieve DNSKEYs for signer domain %s: %v", rrsig.SignerName, err) + continue + } + trace = updatedTrace + } + + keyTag := rrsig.KeyTag + + // Check if the RRSIG is still valid + if !rrsig.ValidityPeriod(time.Now()) { + lastErr = fmt.Errorf("RRSIG with keytag=%d has expired or is not yet valid", keyTag) + v.r.verboseLog(depth, "DNSSEC: RRSIG with keytag=", keyTag, "has expired or is not yet valid") + continue + } + + matchingKey, found := dnskeyMap[keyTag] + if !found { + lastErr = fmt.Errorf("no matching DNSKEY found for RRSIG with key tag %d", keyTag) + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: No matching DNSKEY found for RRSIG with key tag %d", keyTag)) + continue + } + + // Verify the RRSIG with the matching DNSKEY + if err := rrsig.Verify(matchingKey, rrSet); err == nil { + v.dNSKEY[*matchingKey] = true + return rrsig, trace, nil + } + + lastErr = fmt.Errorf("RRSIG with keytag=%d failed to verify", keyTag) + v.r.verboseLog(depth, fmt.Sprintf("DNSSEC: RRSIG with keytag=%d failed to verify", keyTag)) + } + + return nil, trace, errors.Wrap(lastErr, "could not verify any RRSIG for RRset") +} diff --git a/src/zdns/dnssec_types.go b/src/zdns/dnssec_types.go new file mode 100644 index 00000000..35553f57 --- /dev/null +++ b/src/zdns/dnssec_types.go @@ -0,0 +1,155 @@ +/* + * ZDNS Copyright 2024 Regents of the University of Michigan + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package zdns + +import ( + "context" + + "github.com/miekg/dns" +) + +// DNSSECStatus represents the overall validation status according to RFC 4035 +type DNSSECStatus string + +const ( + DNSSECSecure DNSSECStatus = "Secure" + DNSSECInsecure DNSSECStatus = "Insecure" + DNSSECBogus DNSSECStatus = "Bogus" + DNSSECIndeterminate DNSSECStatus = "Indeterminate" +) + +type RRsetKey struct { + Name string `json:"name"` + Type uint16 `json:"type"` + Class uint16 `json:"class"` +} + +func (r *RRsetKey) String() string { + return "name: " + r.Name + ", type: " + dns.TypeToString[r.Type] + ", class: " + dns.ClassToString[r.Class] +} + +// DNSSECPerSetResult represents the validation result for an RRSet +type DNSSECPerSetResult struct { + RRset RRsetKey `json:"rrset"` + Status DNSSECStatus `json:"status"` + Signature *RRSIGAnswer `json:"sig"` + Error string `json:"error"` +} + +// DNSSECResult captures all information generated during a DNSSEC validation +type DNSSECResult struct { + Status DNSSECStatus `json:"status" groups:"dnssec,dnssec,normal,long,trace"` + DS []*DSAnswer `json:"ds" groups:"dnssec,long,trace"` + DNSKEY []*DNSKEYAnswer `json:"dnskey" groups:"dnssec,long,trace"` + Answer []DNSSECPerSetResult `json:"answer" groups:"dnssec,long,trace"` + Additionals []DNSSECPerSetResult `json:"additionals" groups:"dnssec,long,trace"` + Authoritative []DNSSECPerSetResult `json:"authoritative" groups:"dnssec,long,trace"` +} + +type dNSSECValidator struct { + r *Resolver + ctx context.Context + msg *dns.Msg + nameServer *NameServer + isIterative bool + + ds map[dns.DS]bool + dNSKEY map[dns.DNSKEY]bool +} + +// makeDNSSECValidator creates a new DNSSECValidator instance +func makeDNSSECValidator(r *Resolver, ctx context.Context, msg *dns.Msg, nameServer *NameServer, isIterative bool) *dNSSECValidator { + return &dNSSECValidator{ + r: r, + ctx: ctx, + msg: msg, + nameServer: nameServer, + isIterative: isIterative, + + ds: make(map[dns.DS]bool), + dNSKEY: make(map[dns.DNSKEY]bool), + } +} + +// makeDNSSECResult creates and initializes a new DNSSECResult instance +func makeDNSSECResult() *DNSSECResult { + return &DNSSECResult{ + Status: DNSSECIndeterminate, + DS: make([]*DSAnswer, 0), + DNSKEY: make([]*DNSKEYAnswer, 0), + Answer: make([]DNSSECPerSetResult, 0), + Additionals: make([]DNSSECPerSetResult, 0), + Authoritative: make([]DNSSECPerSetResult, 0), + } +} + +// OverallStatus returns the overall validation status. +// If any RR set is bogus, the overall status is bogus. +// If any RR set in answer section or any DNSSEC-related RRSet is insecure, the overall status is insecure. +// If any RR set in answer section or any DNSSEC-related RRSet is indeterminate, the overall status is indeterminate. +// Otherwise, the overall status is secure. +// This function should be called after all PerSetResults are populated, and the result should is stored in r.Status. +func (r *DNSSECResult) populateStatus() { + isDNSSECType := func(rrType uint16) bool { + switch rrType { + case dns.TypeDNSKEY, dns.TypeRRSIG, dns.TypeDS, dns.TypeNSEC, dns.TypeNSEC3, dns.TypeNSEC3PARAM: + return true + default: + return false + } + } + + r.Status = DNSSECSecure + + // Check for bogus results first (highest priority) + checkSections := [][]DNSSECPerSetResult{r.Answer, r.Additionals, r.Authoritative} + for _, section := range checkSections { + for _, result := range section { + if result.Status == DNSSECBogus { + r.Status = DNSSECBogus + return + } + } + } + + for _, result := range r.Answer { + if result.Status == DNSSECInsecure { + r.Status = DNSSECInsecure + return + } + + if result.Status == DNSSECIndeterminate { + r.Status = DNSSECIndeterminate + } + } + + // Check DNSSEC-related RRsets in other sections + for _, section := range [][]DNSSECPerSetResult{r.Additionals, r.Authoritative} { + for _, result := range section { + if isDNSSECType(result.RRset.Type) { + if result.Status == DNSSECInsecure { + r.Status = DNSSECInsecure + return + } + + if r.Status != DNSSECSecure && result.Status == DNSSECIndeterminate { + r.Status = DNSSECIndeterminate + return + } + } + } + } + + // If we get here, either everything is secure or we have an indeterminate result +} diff --git a/src/zdns/lookup.go b/src/zdns/lookup.go index c61b782f..905dc6a9 100644 --- a/src/zdns/lookup.go +++ b/src/zdns/lookup.go @@ -99,15 +99,16 @@ func (r *Resolver) doDstServersLookup(q Question, nameServers []NameServer, isIt } ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() - retries := r.retries + r.retriesRemaining = r.retries questionWithMeta := QuestionWithMetadata{ Q: q, - RetriesRemaining: &retries, + RetriesRemaining: &r.retriesRemaining, } if r.followCNAMEs { return r.followingLookup(ctx, &questionWithMeta, nameServers, isIterative) } - res, trace, status, err := r.lookup(ctx, &questionWithMeta, nameServers, isIterative) + var trace Trace + res, trace, status, err := r.lookup(ctx, &questionWithMeta, nameServers, isIterative, trace) if err != nil { return res, nil, status, fmt.Errorf("could not perform retrying lookup for name %v: %w", q.Name, err) } @@ -115,10 +116,9 @@ func (r *Resolver) doDstServersLookup(q Question, nameServers []NameServer, isIt } // lookup performs a DNS lookup for a given question against a slice of interchangeable nameservers, taking care of iterative and external lookups -func (r *Resolver) lookup(ctx context.Context, qWithMeta *QuestionWithMetadata, nameServers []NameServer, isIterative bool) (*SingleQueryResult, Trace, Status, error) { +func (r *Resolver) lookup(ctx context.Context, qWithMeta *QuestionWithMetadata, nameServers []NameServer, isIterative bool, trace Trace) (*SingleQueryResult, Trace, Status, error) { var res *SingleQueryResult var isCached IsCached - var trace Trace var status Status var err error if util.HasCtxExpired(&ctx) { @@ -132,7 +132,7 @@ func (r *Resolver) lookup(ctx context.Context, qWithMeta *QuestionWithMetadata, tries := 0 // external lookup r.verboseLog(1, "MIEKG-IN: following external lookup for ", qWithMeta.Q.Name, " (", qWithMeta.Q.Type, ")") - res, isCached, status, err = r.cyclingLookup(ctx, qWithMeta, nameServers, qWithMeta.Q.Name, 1, true) + res, isCached, status, trace, err = r.cyclingLookup(ctx, qWithMeta, nameServers, qWithMeta.Q.Name, 1, true, trace) r.verboseLog(1, "MIEKG-OUT: following external lookup for ", qWithMeta.Q.Name, " (", qWithMeta.Q.Type, ") with ", tries, " attempts: status: ", status, " , err: ", err) var t TraceStep // TODO check for null res @@ -149,7 +149,7 @@ func (r *Resolver) lookup(ctx context.Context, qWithMeta *QuestionWithMetadata, t.Depth = 1 t.Cached = isCached t.Try = tries - trace = Trace{t} + trace = append(trace, t) } return res, trace, status, err } @@ -175,11 +175,8 @@ func (r *Resolver) followingLookup(ctx context.Context, qWithMeta *QuestionWithM r.verboseLog(0, "MIEKG-IN: starting a C/DNAME following lookup for ", originalName, " (", qWithMeta.Q.Type, ")") for i := 0; i < r.maxDepth; i++ { qWithMeta.Q.Name = currName // update the question with the current name, this allows following CNAMEs - iterRes, iterTrace, iterStatus, lookupErr := r.lookup(ctx, qWithMeta, nameServers, isIterative) - // append iterTrace to the global trace so we can return full trace - if iterTrace != nil { - trace = append(trace, iterTrace...) - } + iterRes, newTrace, iterStatus, lookupErr := r.lookup(ctx, qWithMeta, nameServers, isIterative, trace) + trace = newTrace if iterStatus != StatusNoError || lookupErr != nil { if i == 0 { // only have 1 result to return @@ -203,14 +200,9 @@ func (r *Resolver) followingLookup(ctx context.Context, qWithMeta *QuestionWithM allAnswerSet = append(allAnswerSet, res.Answers...) if isLookupComplete(originalName, candidateSet, cnameSet, dnameSet) { - return &SingleQueryResult{ - Answers: allAnswerSet, - Additional: res.Additional, - Protocol: res.Protocol, - Resolver: res.Resolver, - Flags: res.Flags, - TLSServerHandshake: res.TLSServerHandshake, - }, trace, StatusNoError, nil + copiedRes := *res + copiedRes.Answers = allAnswerSet + return &copiedRes, trace, StatusNoError, nil } if candidates, ok := cnameSet[currName]; ok && len(candidates) > 0 { @@ -338,7 +330,7 @@ func (r *Resolver) iterativeLookup(ctx context.Context, qWithMeta *QuestionWithM // create iteration context for this iteration step iterationStepCtx, cancel := context.WithTimeout(ctx, r.iterativeTimeout) defer cancel() - result, isCached, status, err := r.cyclingLookup(iterationStepCtx, qWithMeta, nameServers, layer, depth, false) + result, isCached, status, trace, err := r.cyclingLookup(iterationStepCtx, qWithMeta, nameServers, layer, depth, false, trace) if status == StatusNoError && result != nil { var t TraceStep t.Result = *result @@ -388,7 +380,7 @@ func (r *Resolver) iterativeLookup(ctx context.Context, qWithMeta *QuestionWithM // cyclingLookup performs a DNS lookup against a slice of nameservers, cycling through them until a valid response is received. // If the number of retries in QuestionWithMetadata is 0, the function will return an error. -func (r *Resolver) cyclingLookup(ctx context.Context, qWithMeta *QuestionWithMetadata, nameServers []NameServer, layer string, depth int, recursionDesired bool) (*SingleQueryResult, IsCached, Status, error) { +func (r *Resolver) cyclingLookup(ctx context.Context, qWithMeta *QuestionWithMetadata, nameServers []NameServer, layer string, depth int, recursionDesired bool, trace Trace) (*SingleQueryResult, IsCached, Status, Trace, error) { var cacheBasedOnNameServer bool var cacheNonAuthoritative bool if recursionDesired { @@ -410,23 +402,27 @@ func (r *Resolver) cyclingLookup(ctx context.Context, qWithMeta *QuestionWithMet for *qWithMeta.RetriesRemaining >= 0 { if util.HasCtxExpired(&ctx) { - return &SingleQueryResult{}, false, StatusTimeout, nil + return &SingleQueryResult{}, false, StatusTimeout, trace, nil } // get random unqueried nameserver nameServer, queriedNameServers = getRandomNonQueriedNameServer(nameServers, queriedNameServers) // perform the lookup - result, isCached, status, err = r.cachedLookup(ctx, qWithMeta.Q, nameServer, layer, depth, recursionDesired, cacheBasedOnNameServer, cacheNonAuthoritative) + result, isCached, status, trace, err = r.cachedLookup(ctx, qWithMeta.Q, nameServer, layer, depth, recursionDesired, cacheBasedOnNameServer, cacheNonAuthoritative, trace) if status == StatusNoError { r.verboseLog(depth+1, "Cycling lookup successful. Name: ", qWithMeta.Q.Name, ", Layer: ", layer, ", Nameserver: ", nameServer) - return result, isCached, status, err + return result, isCached, status, trace, err } else if *qWithMeta.RetriesRemaining == 0 { r.verboseLog(depth+1, "Cycling lookup failed - out of retries. Name: ", qWithMeta.Q.Name, ", Layer: ", layer, ", Nameserver: ", nameServer) - return result, isCached, status, errors.New("cycling lookup failed - out of retries") + return result, isCached, status, trace, errors.New("cycling lookup failed - out of retries") + } else if !isStatusRetryable(status) { + r.verboseLog(depth+1, "Cycling lookup failed - unretryable status:", status, "Name: ", qWithMeta.Q.Name, ", Layer: ", layer, ", Nameserver: ", nameServer) + return result, isCached, status, trace, err } - r.verboseLog(depth+1, "Cycling lookup failed, using a retry. Retries remaining: ", qWithMeta.RetriesRemaining, " , Name: ", qWithMeta.Q.Name, ", Layer: ", layer, ", Nameserver: ", nameServer) + + r.verboseLog(depth+1, "Cycling lookup failed with status:", status, "err: ", err, ", using a retry. Retries remaining: ", *qWithMeta.RetriesRemaining, " , Name: ", qWithMeta.Q.Name, ", Layer: ", layer, ", Nameserver: ", nameServer) *qWithMeta.RetriesRemaining-- } - return &SingleQueryResult{}, false, StatusError, errors.New("cycling lookup function did not exit properly") + return &SingleQueryResult{}, false, StatusError, trace, errors.New("cycling lookup function did not exit properly") } // getRandomNonQueriedNameServer returns a random name server from the list of name servers that has not been queried yet @@ -452,12 +448,21 @@ func getRandomNonQueriedNameServer(nameServers []NameServer, queriedNameServers // requestIteration is whether to set the "recursion desired" bit in the DNS query // cacheBasedOnNameServer is whether to consider a cache hit based on DNS question and nameserver, or just question // cacheNonAuthoritative is whether to cache non-authoritative answers, usually used for lookups using an external resolver -func (r *Resolver) cachedLookup(ctx context.Context, q Question, nameServer *NameServer, layer string, depth int, requestIteration, cacheBasedOnNameServer, cacheNonAuthoritative bool) (*SingleQueryResult, IsCached, Status, error) { +func (r *Resolver) cachedLookup(ctx context.Context, q Question, nameServer *NameServer, layer string, depth int, requestIteration, cacheBasedOnNameServer, cacheNonAuthoritative bool, trace Trace) (*SingleQueryResult, IsCached, Status, Trace, error) { + // check for circular queries. This may be problematic if NS has circular references and we're trying to perform a DNSSEC validation + if _, ok := r.pendingQueries[q]; ok { + return &SingleQueryResult{}, false, StatusCircular, trace, errors.New("circular query detected") + } + r.pendingQueries[q] = true + defer func() { + delete(r.pendingQueries, q) + }() + var isCached IsCached isCached = false r.verboseLog(depth+1, "Cached retrying lookup. Name: ", q, ", Layer: ", layer, ", Nameserver: ", nameServer) if isValid, reason := nameServer.IsValid(); !isValid { - return &SingleQueryResult{}, false, StatusIllegalInput, fmt.Errorf("invalid nameserver (%s): %s", nameServer.String(), reason) + return &SingleQueryResult{}, false, StatusIllegalInput, trace, fmt.Errorf("invalid nameserver (%s): %s", nameServer.String(), reason) } // create a context for this network lookup lookupCtx, cancel := context.WithTimeout(ctx, r.networkTimeout) @@ -484,15 +489,15 @@ func (r *Resolver) cachedLookup(ctx context.Context, q Question, nameServer *Nam // default to UDP cachedResult.Protocol = UDPProtocol } - return cachedResult, isCached, StatusNoError, nil + return cachedResult, isCached, StatusNoError, trace, nil } // Stop if we hit a nameserver we don't want to hit if r.blacklist != nil { if blacklisted, isBlacklistedErr := r.blacklist.IsBlacklisted(nameServer.IP.String()); isBlacklistedErr != nil { - return nil, isCached, StatusError, errors.Wrapf(isBlacklistedErr, "could not check blacklist for nameserver IP: %s", nameServer.IP.String()) + return nil, isCached, StatusError, trace, errors.Wrapf(isBlacklistedErr, "could not check blacklist for nameserver IP: %s", nameServer.IP.String()) } else if blacklisted { - return &SingleQueryResult{}, isCached, StatusBlacklist, nil + return &SingleQueryResult{}, isCached, StatusBlacklist, trace, nil } } var authName string @@ -507,13 +512,14 @@ func (r *Resolver) cachedLookup(ctx context.Context, q Question, nameServer *Nam authName, err = nextAuthority(name, layer) if err != nil { r.verboseLog(depth+2, err) - return &SingleQueryResult{}, isCached, StatusAuthFail, errors.Wrap(err, "could not get next authority with name: "+name+" and layer: "+layer) + return &SingleQueryResult{}, isCached, StatusAuthFail, trace, errors.Wrap(err, "could not get next authority with name: "+name+" and layer: "+layer) } - if name != layer && authName != layer { + // DS records are special, we need to query the parent zone and therefore cannot use the cache + if name != layer && authName != layer && q.Type != dns.TypeDS { // we have a valid authority to check the cache for if authName == "" { r.verboseLog(depth+2, "Can't parse name to authority properly. name: ", name, ", layer: ", layer) - return &SingleQueryResult{}, isCached, StatusAuthFail, nil + return &SingleQueryResult{}, isCached, StatusAuthFail, trace, nil } r.verboseLog(depth+2, "Cache auth check for ", authName) // TODO - this will need to be changed for AllNameServers @@ -522,7 +528,7 @@ func (r *Resolver) cachedLookup(ctx context.Context, q Question, nameServer *Nam r.verboseLog(depth+2, "Cache auth hit for ", authName) // only want to return if we actually have additionals and authorities from the cache for the caller if len(cachedResult.Additional) > 0 && len(cachedResult.Authorities) > 0 { - return cachedResult, true, StatusNoError, nil + return cachedResult, true, StatusNoError, trace, nil } // unsuccessful in retrieving from the cache, we'll continue to the wire } @@ -533,54 +539,67 @@ func (r *Resolver) cachedLookup(ctx context.Context, q Question, nameServer *Nam r.verboseLog(depth+2, "Cache miss for ", q, ", Layer: ", layer, ", Nameserver: ", nameServer, " going to the wire in retryingLookup") connInfo, err := r.getConnectionInfo(nameServer) if err != nil { - return &SingleQueryResult{}, false, StatusError, fmt.Errorf("could not get a connection info to query nameserver %s: %v", nameServer, err) + return &SingleQueryResult{}, false, StatusError, trace, fmt.Errorf("could not get a connection info to query nameserver %s: %v", nameServer, err) } // check that our connection info is valid if connInfo == nil { - return &SingleQueryResult{}, false, StatusError, fmt.Errorf("no connection info for nameserver: %s", nameServer) + return &SingleQueryResult{}, false, StatusError, trace, fmt.Errorf("no connection info for nameserver: %s", nameServer) } var result *SingleQueryResult + var rawResp *dns.Msg var status Status if r.dnsOverHTTPSEnabled { - r.verboseLog(1, "****WIRE LOOKUP*** ", DoHProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) - result, status, err = doDoHLookup(lookupCtx, connInfo.httpsClient, q, nameServer, requestIteration, r.ednsOptions, r.dnsSecEnabled, r.checkingDisabledBit) + r.verboseLog(depth, "****WIRE LOOKUP*** ", DoHProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) + result, rawResp, status, err = doDoHLookup(lookupCtx, connInfo.httpsClient, q, nameServer, requestIteration, r.ednsOptions, r.dnsSecEnabled, r.checkingDisabledBit) } else if r.dnsOverTLSEnabled { - r.verboseLog(1, "****WIRE LOOKUP*** ", DoTProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) - result, status, err = doDoTLookup(lookupCtx, connInfo, q, nameServer, r.rootCAs, r.verifyServerCert, requestIteration, r.ednsOptions, r.dnsSecEnabled, r.checkingDisabledBit) + r.verboseLog(depth, "****WIRE LOOKUP*** ", DoTProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) + result, rawResp, status, err = doDoTLookup(lookupCtx, connInfo, q, nameServer, r.rootCAs, r.verifyServerCert, requestIteration, r.ednsOptions, r.dnsSecEnabled, r.checkingDisabledBit) } else if connInfo.udpClient != nil { - r.verboseLog(1, "****WIRE LOOKUP*** ", UDPProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) - result, status, err = wireLookupUDP(lookupCtx, connInfo, q, nameServer, r.ednsOptions, requestIteration, r.dnsSecEnabled, r.checkingDisabledBit) + r.verboseLog(depth, "****WIRE LOOKUP*** ", UDPProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) + result, rawResp, status, err = wireLookupUDP(lookupCtx, connInfo, q, nameServer, r.ednsOptions, requestIteration, r.dnsSecEnabled, r.checkingDisabledBit) if status == StatusTruncated && connInfo.tcpClient != nil { // result truncated, try again with TCP - r.verboseLog(1, "****WIRE LOOKUP*** ", TCPProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) - result, status, err = wireLookupTCP(lookupCtx, connInfo, q, nameServer, r.ednsOptions, requestIteration, r.dnsSecEnabled, r.checkingDisabledBit) + r.verboseLog(depth, "****WIRE LOOKUP*** ", TCPProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) + result, rawResp, status, err = wireLookupTCP(lookupCtx, connInfo, q, nameServer, r.ednsOptions, requestIteration, r.dnsSecEnabled, r.checkingDisabledBit) } } else if connInfo.tcpClient != nil { - r.verboseLog(1, "****WIRE LOOKUP*** ", TCPProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) - result, status, err = wireLookupTCP(lookupCtx, connInfo, q, nameServer, r.ednsOptions, requestIteration, r.dnsSecEnabled, r.checkingDisabledBit) + r.verboseLog(depth, "****WIRE LOOKUP*** ", TCPProtocol, " ", dns.TypeToString[q.Type], " ", q.Name, " ", nameServer) + result, rawResp, status, err = wireLookupTCP(lookupCtx, connInfo, q, nameServer, r.ednsOptions, requestIteration, r.dnsSecEnabled, r.checkingDisabledBit) } else { - return &SingleQueryResult{}, false, StatusError, errors.New("no connection info for nameserver") + return &SingleQueryResult{}, false, StatusError, trace, errors.New("no connection info for nameserver") } if err != nil { - return &SingleQueryResult{}, isCached, status, errors.Wrap(err, "could not perform lookup") + return &SingleQueryResult{}, isCached, status, trace, errors.Wrap(err, "could not perform lookup") } - r.verboseLog(depth+2, "Results from wire for name: ", q, ", Layer: ", layer, ", Nameserver: ", nameServer, " status: ", status, " , err: ", err, " result: ", result) + r.verboseLog(depth+2, "Results from wire for name: ", q, ", Layer: ", layer, ", Nameserver: ", nameServer, " status: ", status, " , err: ", err, " result: ", *result) if status == StatusNoError && result != nil { - // only cache answers that don't have errors - if !requestIteration && strings.ToLower(q.Name) != layer && authName != layer && !result.Flags.Authoritative { // TODO - how to detect if we've retrieved an authority record or a answer record? maybe add q.Name != authName - r.verboseLog(depth+2, "Cache auth upsert for ", authName) - r.cache.SafeAddCachedAuthority(result, cacheNameServer, depth+2, layer) + if r.shouldValidateDNSSEC { + validator := makeDNSSECValidator(r, ctx, rawResp, nameServer, !requestIteration) + result.DNSSECResult, trace = validator.validate(depth+2, trace) + r.verboseLog(depth+2, "DNSSEC validation status:", result.DNSSECResult.Status) + } + // only cache answers that don't have errors and pass DNSSEC validation + if !r.shouldValidateDNSSEC || result.DNSSECResult.Status != DNSSECBogus { + if !requestIteration && strings.ToLower(q.Name) != layer && authName != layer && !result.Flags.Authoritative { // TODO - how to detect if we've retrieved an authority record or a answer record? maybe add q.Name != authName + r.verboseLog(depth+2, "Cache auth upsert for ", authName) + r.cache.SafeAddCachedAuthority(result, cacheNameServer, depth+2, layer) + } else { + r.cache.SafeAddCachedAnswer(q, result, cacheNameServer, layer, depth+2, cacheNonAuthoritative) + } } else { - r.cache.SafeAddCachedAnswer(q, result, cacheNameServer, layer, depth+2, cacheNonAuthoritative) + r.verboseLog(depth+2, "skipping cache for domain", q.Name, "and type", dns.TypeToString[q.Type], "due to DNSSEC bogus status") } + } else if r.shouldValidateDNSSEC { + result.DNSSECResult = makeDNSSECResult() } - return result, isCached, status, err + + return result, isCached, status, trace, err } -func doDoTLookup(ctx context.Context, connInfo *ConnectionInfo, q Question, nameServer *NameServer, rootCAs *x509.CertPool, shouldVerifyServerCert, recursive bool, ednsOptions []dns.EDNS0, dnssec bool, checkingDisabled bool) (*SingleQueryResult, Status, error) { +func doDoTLookup(ctx context.Context, connInfo *ConnectionInfo, q Question, nameServer *NameServer, rootCAs *x509.CertPool, shouldVerifyServerCert, recursive bool, ednsOptions []dns.EDNS0, dnssec bool, checkingDisabled bool) (*SingleQueryResult, *dns.Msg, Status, error) { m := new(dns.Msg) m.SetQuestion(dotName(q.Name), q.Type) m.Question[0].Qclass = q.Class @@ -613,7 +632,7 @@ func doDoTLookup(ctx context.Context, connInfo *ConnectionInfo, q Question, name } tcpConn, err := dialer.DialContext(ctx, "tcp", nameServer.String()) if err != nil { - return nil, StatusError, errors.Wrap(err, "could not connect to server") + return nil, nil, StatusError, errors.Wrap(err, "could not connect to server") } // Now wrap the connection with TLS tlsConn := tls.Client(tcpConn, &tls.Config{ @@ -633,18 +652,18 @@ func doDoTLookup(ctx context.Context, connInfo *ConnectionInfo, q Question, name if closeErr != nil { log.Errorf("error closing TLS connection: %v", err) } - return nil, StatusError, errors.Wrap(err, "could not perform TLS handshake") + return nil, nil, StatusError, errors.Wrap(err, "could not perform TLS handshake") } connInfo.tlsHandshake = tlsConn.GetHandshakeLog() connInfo.tlsConn = &dns.Conn{Conn: tlsConn} } err := connInfo.tlsConn.WriteMsg(m) if err != nil { - return nil, "", errors.Wrap(err, "could not write query over DoT to server") + return nil, nil, "", errors.Wrap(err, "could not write query over DoT to server") } responseMsg, err := connInfo.tlsConn.ReadMsg() if err != nil { - return nil, StatusError, errors.Wrap(err, "could not unpack DNS message from DoT server") + return nil, nil, StatusError, errors.Wrap(err, "could not unpack DNS message from DoT server") } res := SingleQueryResult{ Resolver: connInfo.tlsConn.Conn.RemoteAddr().String(), @@ -666,7 +685,7 @@ func doDoTLookup(ctx context.Context, connInfo *ConnectionInfo, q Question, name return constructSingleQueryResultFromDNSMsg(&res, responseMsg) } -func doDoHLookup(ctx context.Context, httpClient *http.Client, q Question, nameServer *NameServer, recursive bool, ednsOptions []dns.EDNS0, dnssec bool, checkingDisabled bool) (*SingleQueryResult, Status, error) { +func doDoHLookup(ctx context.Context, httpClient *http.Client, q Question, nameServer *NameServer, recursive bool, ednsOptions []dns.EDNS0, dnssec bool, checkingDisabled bool) (*SingleQueryResult, *dns.Msg, Status, error) { m := new(dns.Msg) m.SetQuestion(dotName(q.Name), q.Type) m.Question[0].Qclass = q.Class @@ -679,10 +698,10 @@ func doDoHLookup(ctx context.Context, httpClient *http.Client, q Question, nameS } bytes, err := m.Pack() if err != nil { - return nil, StatusError, errors.Wrap(err, "could not pack DNS message") + return nil, nil, StatusError, errors.Wrap(err, "could not pack DNS message") } if strings.Contains(nameServer.DomainName, "http://") { - return nil, StatusError, errors.New("DoH name server must use HTTPS") + return nil, nil, StatusError, errors.New("DoH name server must use HTTPS") } httpsDomain := nameServer.DomainName if !strings.HasPrefix(httpsDomain, "https://") { @@ -693,14 +712,14 @@ func doDoHLookup(ctx context.Context, httpClient *http.Client, q Question, nameS } req, err := http.NewRequest("POST", httpsDomain, strings.NewReader(string(bytes))) if err != nil { - return nil, StatusError, errors.Wrap(err, "could not create HTTP request") + return nil, nil, StatusError, errors.Wrap(err, "could not create HTTP request") } req.Header.Set("Content-Type", "application/dns-message") req.Header.Set("Accept", "application/dns-message") req = req.WithContext(ctx) resp, err := httpClient.Do(req) if err != nil { - return nil, StatusError, errors.Wrap(err, "could not perform HTTP request") + return nil, nil, StatusError, errors.Wrap(err, "could not perform HTTP request") } defer func(Body io.ReadCloser) { err = Body.Close() @@ -710,13 +729,13 @@ func doDoHLookup(ctx context.Context, httpClient *http.Client, q Question, nameS }(resp.Body) bytes, err = io.ReadAll(resp.Body) if err != nil { - return nil, StatusError, errors.Wrap(err, "could not read HTTP response") + return nil, nil, StatusError, errors.Wrap(err, "could not read HTTP response") } r := new(dns.Msg) err = r.Unpack(bytes) if err != nil { - return nil, StatusError, errors.Wrap(err, "could not unpack DNS message") + return nil, nil, StatusError, errors.Wrap(err, "could not unpack DNS message") } res := SingleQueryResult{ Resolver: nameServer.DomainName, @@ -738,7 +757,7 @@ func doDoHLookup(ctx context.Context, httpClient *http.Client, q Question, nameS } // wireLookupTCP performs a DNS lookup on-the-wire over TCP with the given parameters -func wireLookupTCP(ctx context.Context, connInfo *ConnectionInfo, q Question, nameServer *NameServer, ednsOptions []dns.EDNS0, recursive, dnssec, checkingDisabled bool) (*SingleQueryResult, Status, error) { +func wireLookupTCP(ctx context.Context, connInfo *ConnectionInfo, q Question, nameServer *NameServer, ednsOptions []dns.EDNS0, recursive, dnssec, checkingDisabled bool) (*SingleQueryResult, *dns.Msg, Status, error) { res := SingleQueryResult{Answers: []interface{}{}, Authorities: []interface{}{}, Additional: []interface{}{}} res.Resolver = nameServer.String() @@ -761,7 +780,7 @@ func wireLookupTCP(ctx context.Context, connInfo *ConnectionInfo, q Question, na var addr *net.TCPAddr addr, err = net.ResolveTCPAddr("tcp", nameServer.String()) if err != nil { - return nil, StatusError, fmt.Errorf("could not resolve TCP address %s: %v", nameServer.String(), err) + return nil, nil, StatusError, fmt.Errorf("could not resolve TCP address %s: %v", nameServer.String(), err) } r, _, err = connInfo.tcpClient.ExchangeWithConnToContext(ctx, m, connInfo.tcpConn, addr) if err != nil && err.Error() == "EOF" { @@ -782,17 +801,17 @@ func wireLookupTCP(ctx context.Context, connInfo *ConnectionInfo, q Question, na if err != nil || r == nil { if nerr, ok := err.(net.Error); ok { if nerr.Timeout() { - return &res, StatusTimeout, nil + return &res, r, StatusTimeout, nil } } - return &res, StatusError, err + return &res, r, StatusError, err } return constructSingleQueryResultFromDNSMsg(&res, r) } // wireLookupUDP performs a DNS lookup on-the-wire over UDP with the given parameters -func wireLookupUDP(ctx context.Context, connInfo *ConnectionInfo, q Question, nameServer *NameServer, ednsOptions []dns.EDNS0, recursive, dnssec, checkingDisabled bool) (*SingleQueryResult, Status, error) { +func wireLookupUDP(ctx context.Context, connInfo *ConnectionInfo, q Question, nameServer *NameServer, ednsOptions []dns.EDNS0, recursive, dnssec, checkingDisabled bool) (*SingleQueryResult, *dns.Msg, Status, error) { res := SingleQueryResult{Answers: []interface{}{}, Authorities: []interface{}{}, Additional: []interface{}{}} res.Resolver = nameServer.String() res.Protocol = "udp" @@ -817,22 +836,22 @@ func wireLookupUDP(ctx context.Context, connInfo *ConnectionInfo, q Question, na r, _, err = connInfo.udpClient.ExchangeContext(ctx, m, nameServer.String()) } if r != nil && (r.Truncated || r.Rcode == dns.RcodeBadTrunc) { - return &res, StatusTruncated, err + return &res, r, StatusTruncated, err } if err != nil || r == nil { if nerr, ok := err.(net.Error); ok { if nerr.Timeout() { - return &res, StatusTimeout, nil + return &res, r, StatusTimeout, nil } } - return &res, StatusError, err + return &res, r, StatusError, err } return constructSingleQueryResultFromDNSMsg(&res, r) } // fills out all the fields in a SingleQueryResult from a dns.Msg directly. -func constructSingleQueryResultFromDNSMsg(res *SingleQueryResult, r *dns.Msg) (*SingleQueryResult, Status, error) { +func constructSingleQueryResultFromDNSMsg(res *SingleQueryResult, r *dns.Msg) (*SingleQueryResult, *dns.Msg, Status, error) { if r.Rcode != dns.RcodeSuccess { for _, ans := range r.Extra { inner := ParseAnswer(ans) @@ -840,7 +859,7 @@ func constructSingleQueryResultFromDNSMsg(res *SingleQueryResult, r *dns.Msg) (* res.Additional = append(res.Additional, inner) } } - return res, TranslateDNSErrorCode(r.Rcode), nil + return res, r, TranslateDNSErrorCode(r.Rcode), nil } res.Flags.Response = r.Response @@ -871,29 +890,44 @@ func constructSingleQueryResultFromDNSMsg(res *SingleQueryResult, r *dns.Msg) (* res.Authorities = append(res.Authorities, inner) } } - return res, StatusNoError, nil + return res, r, StatusNoError, nil } func (r *Resolver) iterateOnAuthorities(ctx context.Context, qWithMeta *QuestionWithMetadata, depth int, result *SingleQueryResult, layer string, trace Trace) (*SingleQueryResult, Trace, Status, error) { if len(result.Authorities) == 0 { return nil, trace, StatusNoAuth, nil } - var newLayer string - newTrace := trace - nameServers := make([]NameServer, 0, len(result.Authorities)) - for i, elem := range result.Authorities { + + // Shuffle authorities to try them in random order + authorities := make([]interface{}, len(result.Authorities)) + copy(authorities, result.Authorities) + rand.Shuffle(len(authorities), func(i, j int) { + authorities[i], authorities[j] = authorities[j], authorities[i] + }) + + for _, elem := range authorities { + // Skip DS and RRSIG records + switch elem.(type) { + case DSAnswer, RRSIGAnswer: + continue + } + if util.HasCtxExpired(&ctx) { - return &SingleQueryResult{}, newTrace, StatusTimeout, nil + return &SingleQueryResult{}, trace, StatusTimeout, nil } - var ns *NameServer - var nsStatus Status + r.verboseLog(depth+1, "Trying Authority: ", elem) - ns, nsStatus, newLayer, newTrace = r.extractAuthority(ctx, elem, layer, qWithMeta.RetriesRemaining, depth, result, newTrace) + + // Extract authority details + ns, nsStatus, nextLayer, newTrace := r.extractAuthority(ctx, elem, layer, depth, result, trace) + trace = newTrace r.verboseLog(depth+1, "Output from extract authorities: ", ns.String()) + if nsStatus == StatusIterTimeout { - r.verboseLog(depth+2, "--> Hit iterative timeout: ") - return &SingleQueryResult{}, newTrace, StatusIterTimeout, nil + r.verboseLog(depth+2, "--> Hit iterative timeout") + return &SingleQueryResult{}, trace, StatusIterTimeout, nil } + if nsStatus != StatusNoError { var err error newStatus, err := handleStatus(nsStatus, err) @@ -902,35 +936,32 @@ func (r *Resolver) iterateOnAuthorities(ctx context.Context, qWithMeta *Question } else { r.verboseLog(depth+2, "--> Auth find failed for name ", qWithMeta.Q.Name, " with status: ", newStatus) } - if i+1 == len(result.Authorities) { - r.verboseLog(depth+2, "--> No more authorities to try for name ", qWithMeta.Q.Name, ", terminating: ", nsStatus) - } - } else { - // We have a valid nameserver - nameServers = append(nameServers, *ns) + continue } - } - if len(nameServers) == 0 { - r.verboseLog(depth+1, fmt.Sprintf("--> Auth found no valid nameservers for name: %s Terminating", qWithMeta.Q.Name)) - return &SingleQueryResult{}, newTrace, StatusServFail, errors.New("no valid nameservers found") - } + // Try iterative lookup immediately with this nameserver + iterateResult, newTrace, status, err := r.iterativeLookup(ctx, qWithMeta, []NameServer{*ns}, depth+1, nextLayer, trace) + trace = newTrace - iterateResult, newTrace, status, err := r.iterativeLookup(ctx, qWithMeta, nameServers, depth+1, newLayer, newTrace) - if status == StatusNoNeededGlue { - r.verboseLog((depth + 2), "--> Auth resolution of ", nameServers, " was unsuccessful. No glue to follow", status) - return iterateResult, newTrace, status, err - } else if isStatusAnswer(status) { - r.verboseLog((depth + 1), "--> Auth Resolution of ", nameServers, " success: ", status) - return iterateResult, newTrace, status, err - } else { - // We don't allow the continue fall through in order to report the last auth failure code, not STATUS_ERROR - r.verboseLog((depth + 2), "--> Iterative resolution of ", qWithMeta.Q.Name, " at ", nameServers, " Failed. Last auth. Terminating: ", status) - return iterateResult, newTrace, status, err + if status == StatusNoNeededGlue { + r.verboseLog(depth+2, "--> Auth resolution of ", ns, " was unsuccessful. No glue to follow") + continue + } + + if isStatusAnswer(status) { + r.verboseLog(depth+1, "--> Auth Resolution of ", ns, " success: ", status) + return iterateResult, trace, status, err + } + + r.verboseLog(depth+2, "--> Iterative resolution of ", qWithMeta.Q.Name, " at ", ns, " Failed: ", status) } + + // If we get here, all authorities failed + r.verboseLog(depth+2, "--> No more authorities to try for name ", qWithMeta.Q.Name, ", terminating") + return &SingleQueryResult{}, trace, StatusServFail, errors.New("no valid nameservers found or all lookups failed") } -func (r *Resolver) extractAuthority(ctx context.Context, authority interface{}, layer string, retriesRemaining *int, depth int, result *SingleQueryResult, trace Trace) (*NameServer, Status, string, Trace) { +func (r *Resolver) extractAuthority(ctx context.Context, authority interface{}, layer string, depth int, result *SingleQueryResult, trace Trace) (*NameServer, Status, string, Trace) { // Is it an answer ans, ok := authority.(Answer) if !ok { @@ -964,8 +995,13 @@ func (r *Resolver) extractAuthority(ctx context.Context, authority interface{}, } else { q.Q.Type = dns.TypeA } - q.RetriesRemaining = retriesRemaining + q.RetriesRemaining = &r.retriesRemaining + + // A/AAAA records for NSes are not on the chain of trust, so we don't need to validate DNSSEC + // Doing this to save us some time (this can propogate A LOT of queries in certain cases) + r.shouldValidateDNSSEC = false res, trace, status, _ = r.iterativeLookup(ctx, &q, r.rootNameServers, depth+1, ".", trace) + r.shouldValidateDNSSEC = true } if status == StatusIterTimeout || status == StatusNoNeededGlue { return nil, status, "", trace diff --git a/src/zdns/qa.go b/src/zdns/qa.go index 4e949a94..6ad8f431 100644 --- a/src/zdns/qa.go +++ b/src/zdns/qa.go @@ -81,6 +81,7 @@ type SingleQueryResult struct { Protocol string `json:"protocol" groups:"protocol,normal,long,trace"` Resolver string `json:"resolver" groups:"resolver,normal,long,trace"` Flags DNSFlags `json:"flags" groups:"flags,long,trace"` + DNSSECResult *DNSSECResult `json:"dnssec,omitempty" groups:"dnssec,normal,long,trace"` TLSServerHandshake interface{} `json:"tls_handshake,omitempty" groups:"normal,long,trace"` // used for --tls and --https, JSON string of the TLS handshake } diff --git a/src/zdns/resolver.go b/src/zdns/resolver.go index fa496fc9..e60f9c6d 100644 --- a/src/zdns/resolver.go +++ b/src/zdns/resolver.go @@ -49,6 +49,7 @@ const ( defaultCacheSize = 10000 defaultShouldTrace = false defaultDNSSECEnabled = false + defaultShouldValidateDNSSEC = false defaultIPVersionMode = IPv4Only defaultIterationIPPreference = PreferIPv4 DefaultNameServerConfigFile = "/etc/resolv.conf" @@ -88,15 +89,16 @@ type ResolverConfig struct { FollowCNAMEs bool // whether iterative lookups should follow CNAMEs/DNAMEs DNSConfigFilePath string // path to the DNS config file, ex: /etc/resolv.conf - DNSSecEnabled bool - DNSOverHTTPS bool // whether to use DNS over HTTPS for External Lookups, n/a to Iterative Lookups - DNSOverTLS bool // whether to use DNS over TLS for External Lookups, n/a to Iterative Lookups - RootCAs *x509.CertPool // Root CAs for DoT/DoH Server Verification - VerifyServerCert bool // Verify server certificates for DoT/DoH - HTTPSClientIPv4 *http.Client // for DoH, per docs should be shared amongst requests - HTTPSClientIPv6 *http.Client // for DoH, per docs should be shared amongst requests - EdnsOptions []dns.EDNS0 - CheckingDisabledBit bool + DNSSecEnabled bool + ShouldValidateDNSSEC bool // whether to validate DNSSEC + DNSOverHTTPS bool // whether to use DNS over HTTPS for External Lookups, n/a to Iterative Lookups + DNSOverTLS bool // whether to use DNS over TLS for External Lookups, n/a to Iterative Lookups + RootCAs *x509.CertPool // Root CAs for DoT/DoH Server Verification + VerifyServerCert bool // Verify server certificates for DoT/DoH + HTTPSClientIPv4 *http.Client // for DoH, per docs should be shared amongst requests + HTTPSClientIPv6 *http.Client // for DoH, per docs should be shared amongst requests + EdnsOptions []dns.EDNS0 + CheckingDisabledBit bool } // Validate checks if the ResolverConfig is valid, returns an error describing the issue if it is not. @@ -240,8 +242,9 @@ func NewResolverConfig() *ResolverConfig { NetworkTimeout: defaultNetworkTimeout, MaxDepth: defaultMaxDepth, - DNSSecEnabled: defaultDNSSECEnabled, - CheckingDisabledBit: defaultCheckingDisabledBit, + DNSSecEnabled: defaultDNSSECEnabled, + ShouldValidateDNSSEC: defaultShouldValidateDNSSEC, + CheckingDisabledBit: defaultCheckingDisabledBit, } } @@ -269,8 +272,10 @@ type Resolver struct { connInfoIPv4Loopback *ConnectionInfo // used for IPv4 lookups to loopback nameservers connInfoIPv6Loopback *ConnectionInfo // used for IPv6 lookups to loopback nameservers - retries int - logLevel log.Level + retries int // constant, configured max number of retries + retriesRemaining int // number of retries left in the current lookup + pendingQueries map[Question]bool // map of pending queries, to prevent cyclic queries + logLevel log.Level transportMode transportMode ipVersionMode IPVersionMode @@ -287,14 +292,15 @@ type Resolver struct { lookupAllNameServers bool followCNAMEs bool // whether iterative lookups should follow CNAMEs/DNAMEs - dnsSecEnabled bool - dnsOverHTTPSEnabled bool // whether to use DNS over HTTPS for External Lookups, n/a to Iterative Lookups - dnsOverTLSEnabled bool // whether to use DNS over TLS for External Lookups, n/a to Iterative Lookups - rootCAs *x509.CertPool // Root CAs for DoT/DoH Server Verification - verifyServerCert bool // Verify server certificates for DoT/DoH - ednsOptions []dns.EDNS0 - checkingDisabledBit bool - isClosed bool // true if the resolver has been closed, lookup will panic if called after Close + dnsSecEnabled bool + shouldValidateDNSSEC bool // whether to validate DNSSEC + dnsOverHTTPSEnabled bool // whether to use DNS over HTTPS for External Lookups, n/a to Iterative Lookups + dnsOverTLSEnabled bool // whether to use DNS over TLS for External Lookups, n/a to Iterative Lookups + rootCAs *x509.CertPool // Root CAs for DoT/DoH Server Verification + verifyServerCert bool // Verify server certificates for DoT/DoH + ednsOptions []dns.EDNS0 + checkingDisabledBit bool + isClosed bool // true if the resolver has been closed, lookup will panic if called after Close } // InitResolver creates a new Resolver struct using the ResolverConfig. The Resolver is used to perform DNS lookups. @@ -323,6 +329,7 @@ func InitResolver(config *ResolverConfig) (*Resolver, error) { retries: config.Retries, logLevel: config.LogLevel, + pendingQueries: make(map[Question]bool), lookupAllNameServers: config.LookupAllNameServers, transportMode: config.TransportMode, @@ -333,13 +340,14 @@ func InitResolver(config *ResolverConfig) (*Resolver, error) { timeout: config.Timeout, - dnsOverHTTPSEnabled: config.DNSOverHTTPS, - dnsOverTLSEnabled: config.DNSOverTLS, - rootCAs: config.RootCAs, - verifyServerCert: config.VerifyServerCert, - dnsSecEnabled: config.DNSSecEnabled, - ednsOptions: config.EdnsOptions, - checkingDisabledBit: config.CheckingDisabledBit, + dnsOverHTTPSEnabled: config.DNSOverHTTPS, + dnsOverTLSEnabled: config.DNSOverTLS, + rootCAs: config.RootCAs, + verifyServerCert: config.VerifyServerCert, + dnsSecEnabled: config.DNSSecEnabled, + shouldValidateDNSSEC: config.ShouldValidateDNSSEC, + ednsOptions: config.EdnsOptions, + checkingDisabledBit: config.CheckingDisabledBit, } log.SetLevel(r.logLevel) // Deep copy local address so Resolver is independent of the config diff --git a/src/zdns/util.go b/src/zdns/util.go index 8036da18..a47a906f 100644 --- a/src/zdns/util.go +++ b/src/zdns/util.go @@ -29,9 +29,24 @@ import ( const ZDNSVersion = "1.1.0" func dotName(name string) string { + if name == "." { + return name + } + + if strings.HasSuffix(name, ".") { + log.Fatal("name already has trailing dot") + } + return strings.Join([]string{name, "."}, "") } +func removeTrailingDotIfNotRoot(name string) string { + if name == "." { + return name + } + return strings.TrimSuffix(name, ".") +} + func TranslateMiekgErrorCode(err int) Status { return Status(dns.RcodeToString[err]) } @@ -120,6 +135,10 @@ func nextAuthority(name, layer string) (string, error) { return "in-addr.arpa", nil } + if name == "." && layer == "." { + return ".", nil + } + idx := strings.LastIndex(name, ".") if idx < 0 || (idx+1) >= len(name) { return name, nil diff --git a/testing/integration_tests.py b/testing/integration_tests.py index 222d79ee..3f6da0e8 100755 --- a/testing/integration_tests.py +++ b/testing/integration_tests.py @@ -1288,6 +1288,55 @@ def test_cd_bit_set(self): cmd, res = self.run_zdns(c, name) self.assertSuccess(res, cmd, "A") + def test_dnssec_validation_secure(self): + # checks if dnssec validation is performed + DOMAINS = [ + "cloudflare.com", + "internetsociety.org", + "dnssec-tools.org", + "dnssec-deployment.org", + ] + for domain in DOMAINS: + c = f"A {domain} --iterative --validate-dnssec --result-verbosity=long" + name = "." + cmd, res = self.run_zdns(c, name) + self.assertSuccess(res, cmd, "A") + dnssec = res["results"]["A"]["data"]["dnssec"] + self.assertEqual(dnssec["status"], "Secure") + self.assertTrue(len(dnssec["ds"]) > 0) + self.assertTrue(len(dnssec["dnskey"]) > 0) + + def test_dnssec_validation_secure_circular(self): + # checks if dnssec validation can handle circular NS dependencies + c = "A example.com --iterative --validate-dnssec --result-verbosity=long" + name = "." + cmd, res = self.run_zdns(c, name) + self.assertSuccess(res, cmd, "A") + dnssec = res["results"]["A"]["data"]["dnssec"] + self.assertEqual(dnssec["status"], "Secure") + + def test_dnssec_validation_insecure(self): + # checks if dnssec validation reports insecure (not signed) zones correctly + c = "A outlook.com --iterative --validate-dnssec --result-verbosity=long" + name = "." + cmd, res = self.run_zdns(c, name) + self.assertSuccess(res, cmd, "A") + dnssec = res["results"]["A"]["data"]["dnssec"] + self.assertEqual(dnssec["status"], "Insecure") + self.assertTrue(len(dnssec["ds"]) == 0) + self.assertTrue(len(dnssec["dnskey"]) == 0) + + def test_dnssec_validation_bogus(self): + # checks if dnssec validation reports bogus zones correctly + DOMAINS = ["dnssec-failed.org", "rhybar.cz"] + for domain in DOMAINS: + c = f"A {domain} --iterative --validate-dnssec --result-verbosity=long" + name = "." + cmd, res = self.run_zdns(c, name) + self.assertSuccess(res, cmd, "A") + dnssec = res["results"]["A"]["data"]["dnssec"] + self.assertEqual(dnssec["status"], "Bogus") + def test_timetamps(self): c = "A" name = "zdns-testing.com"