- Sponsor
-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
38 changed files
with
2,939 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
FROM qmcgaw/godevcontainer | ||
RUN apk add bind-tools |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,5 +16,6 @@ services: | |
- UPDATE_PERIOD=24h | ||
ports: | ||
- 53:53/udp | ||
- 53:53/tcp | ||
network_mode: bridge | ||
restart: always |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
// buildDelegationChain queries the RRs required for the zone validation. | ||
// It begins the queries at the root zone and then go down the delegation | ||
// chain until it reaches the desired zone, or an unsigned zone. | ||
// It returns a delegation chain of signed zones where the | ||
// first signed zone (index 0) is the root zone and the last signed | ||
// zone is the last signed zone, which can be the desired zone. | ||
func buildDelegationChain(handler dns.Handler, desiredZone string, qClass uint16) ( | ||
delegationChain []signedData, err error) { | ||
zoneNames := desiredZoneToZoneNames(desiredZone) | ||
delegationChain = make([]signedData, 0, len(zoneNames)) | ||
|
||
for _, zoneName := range zoneNames { | ||
// zoneName iterates in this order: ., com., example.com. | ||
data, signed, err := queryDelegation(handler, zoneName, qClass) | ||
if err != nil { | ||
return nil, fmt.Errorf("querying delegation for desired zone %s: %w", | ||
desiredZone, err) | ||
} | ||
delegationChain = append(delegationChain, data) | ||
if !signed { | ||
// first zone without a DS RRSet, but it should | ||
// have at least one NSEC or NSEC3 RRSet, even for | ||
// NXDOMAIN responses. | ||
break | ||
} | ||
} | ||
|
||
return delegationChain, nil | ||
} | ||
|
||
func desiredZoneToZoneNames(desiredZone string) (zoneNames []string) { | ||
if desiredZone == "." { | ||
return []string{"."} | ||
} | ||
|
||
zoneParts := strings.Split(desiredZone, ".") | ||
zoneNames = make([]string, len(zoneParts)) | ||
for i := range zoneParts { | ||
zoneNames[i] = dns.Fqdn(strings.Join(zoneParts[len(zoneParts)-1-i:], ".")) | ||
} | ||
return zoneNames | ||
} | ||
|
||
// queryDelegation obtains the DS RRSet and the DNSKEY RRSet | ||
// for a given zone and class, and creates a signed zone with | ||
// this information. It does not query the (non existent) | ||
// DS record for the root zone, which is the trust root anchor. | ||
func queryDelegation(handler dns.Handler, zone string, qClass uint16) ( | ||
data signedData, signed bool, err error) { | ||
data.zone = zone | ||
data.class = qClass | ||
|
||
// TODO set root zone DS here! | ||
|
||
// do not query DS for root zone since its DS record | ||
// is the trust root anchor. | ||
if zone != "." { | ||
data.dsResponse, err = queryDS(handler, zone, qClass) | ||
if err != nil { | ||
return signedData{}, false, fmt.Errorf("querying DS record: %w", err) | ||
} | ||
|
||
if data.dsResponse.isNoData() || data.dsResponse.isNXDomain() { | ||
// If no DS RRSet is found, the entire zone is unsigned. | ||
// This also means no DNSKEY RRSet exists, since child zones are | ||
// also unsigned, so return with the error errZoneHasNoDSRcord | ||
// to signal the caller to stop the delegation chain queries for | ||
// child zones when encountering a zone with no DS RRSet. | ||
return data, false, nil | ||
} | ||
} | ||
|
||
data.dnsKeyResponse, err = queryDNSKeys(handler, zone, qClass) | ||
if err != nil { | ||
return signedData{}, true, fmt.Errorf("querying DNSKEY record: %w", err) | ||
} | ||
|
||
return data, true, nil | ||
} | ||
|
||
var ( | ||
ErrDSAndNSECAbsent = errors.New("zone has no DS record and no NSEC record") | ||
) | ||
|
||
func queryDS(handler dns.Handler, zone string, qClass uint16) ( | ||
response dnssecResponse, err error) { | ||
response, err = queryRRSets(handler, zone, qClass, dns.TypeDS) | ||
switch { | ||
case err != nil: | ||
return dnssecResponse{}, err | ||
case !response.isSigned(): | ||
// no signed DS answer and no NSEC/NSEC3 authority RR | ||
return dnssecResponse{}, wrapError( | ||
zone, qClass, dns.TypeDS, ErrDSAndNSECAbsent) | ||
case response.isNXDomain(), response.isNoData(): | ||
// there is one or more NSEC/NSEC3 authority RRSets. | ||
return response, nil | ||
} | ||
// signed answer RRSet(s) | ||
|
||
// Double check we only have 1 DS RRSet. | ||
// TODO remove? | ||
err = dnssecRRSetsIsSingleOfType(response.answerRRSets, dns.TypeDS) | ||
if err != nil { | ||
return dnssecResponse{}, | ||
wrapError(zone, qClass, dns.TypeDS, err) | ||
} | ||
|
||
return response, nil | ||
} | ||
|
||
// queryDNSKeys queries the DNSKEY records for a given signed zone | ||
// containing a DS RRSet. It returns an error if the DNSKEY RRSet is | ||
// missing or is unsigned. | ||
// Note this returns all the DNSKey RRs, even non-zone ones. | ||
func queryDNSKeys(handler dns.Handler, qname string, qClass uint16) ( | ||
response dnssecResponse, err error) { | ||
// DNSKey RRSet(s) should be present so the NSEC/NSEC3 RRSet is ignored. | ||
response, err = queryRRSets(handler, qname, qClass, dns.TypeDNSKEY) | ||
switch { | ||
case err != nil: | ||
return dnssecResponse{}, err | ||
case !response.isSigned(), response.isNoData(): // cannot be NXDOMAIN | ||
// no signed DNSKEY answer | ||
return dnssecResponse{}, fmt.Errorf("for %s: %w", | ||
nameClassTypeToString(qname, qClass, dns.TypeDNSKEY), | ||
ErrDNSKeyNotFound) | ||
} | ||
|
||
// Double check we only have 1 DNSKEY RRSet. | ||
// TODO remove? | ||
err = dnssecRRSetsIsSingleOfType(response.answerRRSets, dns.TypeDNSKEY) | ||
if err != nil { | ||
return dnssecResponse{}, | ||
wrapError(qname, qClass, dns.TypeDNSKEY, err) | ||
} | ||
|
||
return response, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package dnssec | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func Test_desiredZoneToZoneNames(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
desiredZone string | ||
zoneNames []string | ||
}{ | ||
"root": { | ||
desiredZone: ".", | ||
zoneNames: []string{"."}, | ||
}, | ||
"com": { | ||
desiredZone: "com.", | ||
zoneNames: []string{".", "com."}, | ||
}, | ||
"example.com": { | ||
desiredZone: "example.com.", | ||
zoneNames: []string{".", "com.", "example.com."}, | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
zoneNames := desiredZoneToZoneNames(testCase.desiredZone) | ||
assert.Equal(t, testCase.zoneNames, zoneNames) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
func mustRRToCNAME(rr dns.RR) *dns.CNAME { | ||
cname, ok := rr.(*dns.CNAME) | ||
if !ok { | ||
panic(fmt.Sprintf("RR is of type %T and not of type *dns.CNAME", rr)) | ||
} | ||
return cname | ||
} | ||
|
||
func getCnameTarget(rrSets []dnssecRRSet) (target string) { | ||
for _, rrSet := range rrSets { | ||
if rrSet.qtype() == dns.TypeCNAME { | ||
cname := mustRRToCNAME(rrSet.rrSet[0]) | ||
return cname.Target | ||
} | ||
} | ||
return "" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
func mustRRToDNSKey(rr dns.RR) *dns.DNSKEY { | ||
dnsKey, ok := rr.(*dns.DNSKEY) | ||
if !ok { | ||
panic(fmt.Sprintf("RR is of type %T and not of type *dns.DNSKEY", rr)) | ||
} | ||
return dnsKey | ||
} | ||
|
||
// makeKeyTagToDNSKey creates a map of key tag to DNSKEY from a DNSKEY RRSet, | ||
// ignoring any RR which is not a Zone signing key. | ||
func makeKeyTagToDNSKey(dnsKeyRRSet []dns.RR) (keyTagToDNSKey map[uint16]*dns.DNSKEY) { | ||
keyTagToDNSKey = make(map[uint16]*dns.DNSKEY, len(dnsKeyRRSet)) | ||
for _, dnsKeyRR := range dnsKeyRRSet { | ||
dnsKey := mustRRToDNSKey(dnsKeyRR) | ||
if dnsKey.Flags&dns.ZONE == 0 { | ||
// As described in https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.1 | ||
// and https://datatracker.ietf.org/doc/html/rfc4034#section-5.2: | ||
// If bit 7 has value 0, then the DNSKEY record holds some other type of DNS | ||
// public key and MUST NOT be used to verify RRSIGs that cover RRsets. | ||
// The DNSKEY RR Flags MUST have Flags bit 7 set. If the | ||
// DNSKEY flags do not indicate a DNSSEC zone key, the DS | ||
// RR (and the DNSKEY RR it references) MUST NOT be used | ||
// in the validation process. | ||
continue | ||
} | ||
keyTagToDNSKey[dnsKey.KeyTag()] = dnsKey | ||
} | ||
return keyTagToDNSKey | ||
} | ||
|
||
const ( | ||
algoPreferenceRecommended uint8 = iota | ||
algoPreferenceMust | ||
algoPreferenceMay | ||
algoPreferenceMustNot | ||
algoPreferenceUnknown | ||
) | ||
|
||
// lessDNSKeyAlgorithm returns true if algoID1 < algoID2 in terms | ||
// of preference. The preference is determined by the table defined in: | ||
// https://datatracker.ietf.org/doc/html/rfc8624#section-3.1 | ||
func lessDNSKeyAlgorithm(algoID1, algoID2 uint8) bool { | ||
return algoIDToPreference(algoID1) < algoIDToPreference(algoID2) | ||
} | ||
|
||
// algoIDToPreference returns the preference level of the algorithm ID. | ||
// Note this is a function with a switch statement, which not only provide | ||
// immutability compared to a global variable map, but is also x10 faster | ||
// than map lookups. | ||
func algoIDToPreference(algoID uint8) (preference uint8) { | ||
switch algoID { | ||
case dns.RSAMD5, dns.DSA, dns.DSANSEC3SHA1: | ||
return algoPreferenceMustNot | ||
case dns.ECCGOST: | ||
return algoPreferenceMay | ||
case dns.RSASHA1, dns.RSASHA1NSEC3SHA1, dns.RSASHA256, dns.RSASHA512, dns.ECDSAP256SHA256: | ||
return algoPreferenceMust | ||
case dns.ECDSAP384SHA384, dns.ED25519, dns.ED448: | ||
return algoPreferenceRecommended | ||
default: | ||
return algoPreferenceUnknown | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package dnssec | ||
|
||
import "testing" | ||
|
||
var testGlobalMap = map[uint8]uint8{ //nolint:gochecknoglobals | ||
1: 1, | ||
2: 2, | ||
3: 3, | ||
4: 4, | ||
5: 5, | ||
6: 6, | ||
7: 7, | ||
8: 8, | ||
} | ||
|
||
func testSwitchStatement(key uint8) uint8 { | ||
switch key { | ||
case 1: | ||
return 1 | ||
case 2: | ||
return 2 | ||
case 3: | ||
return 3 | ||
case 4: | ||
return 4 | ||
case 5: | ||
return 5 | ||
case 6: | ||
return 6 | ||
case 7: | ||
return 7 | ||
case 8: | ||
return 8 | ||
default: | ||
return 0 // TODO replace with panic | ||
} | ||
} | ||
|
||
// This benchmark aims to check if, for algoIDToPreference, it is | ||
// better to: | ||
// 1. have a global map variable | ||
// 2. have a function with a switch statement | ||
// The second point at equal performance is better due to its | ||
// immutability nature, unlike 1. | ||
func Benchmark_globalMap_switch(b *testing.B) { | ||
b.Run("global_map", func(b *testing.B) { | ||
for i := 0; i < b.N; i++ { | ||
_ = testGlobalMap[1] | ||
} | ||
}) | ||
|
||
b.Run("switch", func(b *testing.B) { | ||
for i := 0; i < b.N; i++ { | ||
_ = testSwitchStatement(1) | ||
} | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/qdm12/dns/v2/internal/local" | ||
) | ||
|
||
var ( | ||
ErrQuestionsMultiple = errors.New("multiple questions") | ||
) | ||
|
||
func Validate(request *dns.Msg, handler dns.Handler) (response *dns.Msg, err error) { | ||
switch len(request.Question) { | ||
case 0: | ||
response = new(dns.Msg) | ||
response.SetRcode(request, dns.RcodeSuccess) | ||
return response, nil | ||
case 1: | ||
default: | ||
return nil, fmt.Errorf("%w: %d", ErrQuestionsMultiple, len(request.Question)) | ||
} | ||
|
||
desiredZone := request.Question[0].Name | ||
qType := request.Question[0].Qtype | ||
qClass := request.Question[0].Qclass | ||
desiredResponse, err := queryRRSets(handler, desiredZone, qClass, qType) | ||
if err != nil { | ||
return nil, fmt.Errorf("running desired query: %w", err) | ||
} | ||
|
||
if local.IsFQDNLocal(desiredZone) { | ||
// Do not perform DNSSEC validation for local zones | ||
return desiredResponse.ToDNSMsg(request), nil | ||
} | ||
|
||
originalDesiredZone := desiredZone | ||
cnameTarget := getCnameTarget(desiredResponse.answerRRSets) | ||
if cnameTarget != "" { | ||
desiredZone = cnameTarget | ||
} | ||
|
||
delegationChain, err := buildDelegationChain(handler, desiredZone, qClass) | ||
if err != nil { | ||
return nil, fmt.Errorf("building delegation chain for %s: %w", | ||
originalDesiredZone, err) | ||
} | ||
|
||
err = validateWithChain(desiredZone, qType, desiredResponse, delegationChain) | ||
if err != nil { | ||
return nil, fmt.Errorf("for %s: validating answer RRSets"+ | ||
" with delegation chain: %w", | ||
nameClassTypeToString(originalDesiredZone, qClass, qType), err) | ||
} | ||
|
||
return desiredResponse.ToDNSMsg(request), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
func mustRRToDS(rr dns.RR) *dns.DS { | ||
ds, ok := rr.(*dns.DS) | ||
if !ok { | ||
panic(fmt.Sprintf("RR is of type %T and not of type *dns.DS", rr)) | ||
} | ||
return ds | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package dnssec | ||
|
||
import "github.com/miekg/dns" | ||
|
||
func newEDNSRequest(zone string, qClass, qType uint16) (request *dns.Msg) { | ||
request = new(dns.Msg).SetQuestion(zone, qType) | ||
request.Question[0].Qclass = qClass | ||
request.RecursionDesired = true | ||
const maxUDPSize = 4096 | ||
const doEdns0 = true | ||
request.SetEdns0(maxUDPSize, doEdns0) | ||
return request | ||
} | ||
|
||
func isRequestAskingForDNSSEC(request *dns.Msg) bool { | ||
opt := request.IsEdns0() | ||
if opt == nil { | ||
return false | ||
} | ||
|
||
// See https://datatracker.ietf.org/doc/html/rfc6891#section-6.2.3 | ||
const minUDPSize = 512 | ||
|
||
return opt.Hdr.Name == "." && | ||
opt.Hdr.Rrtype == dns.TypeOPT && | ||
opt.Hdr.Class >= minUDPSize && // UDP size | ||
opt.Do() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package dnssec | ||
|
||
import "errors" | ||
|
||
var ( | ||
// TODO review exported errors usage and all sentinel errors. | ||
ErrBogus = errors.New("bogus response") | ||
) | ||
|
||
var _ error = (*joinedErrors)(nil) | ||
|
||
type joinedErrors struct { //nolint:errname | ||
errs []error | ||
} | ||
|
||
func (e *joinedErrors) add(err error) { | ||
e.errs = append(e.errs, err) | ||
} | ||
|
||
func (e *joinedErrors) Error() string { | ||
return joinStrings(e.errs, "and") | ||
} | ||
|
||
func (e *joinedErrors) Unwrap() []error { | ||
return e.errs | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/miekg/dns" | ||
"golang.org/x/exp/constraints" | ||
) | ||
|
||
func nameClassTypeToString(qname string, qClass, qType uint16) string { | ||
return qname + " " + dns.ClassToString[qClass] + " " + dns.TypeToString[qType] | ||
} | ||
|
||
func nameTypeToString(qname string, qType uint16) string { | ||
return qname + " " + dns.TypeToString[qType] | ||
} | ||
|
||
func hashToString(hashType uint8) string { | ||
s, ok := dns.HashToString[hashType] | ||
if ok { | ||
return s | ||
} | ||
return fmt.Sprintf("%d", hashType) | ||
} | ||
|
||
func hashesToString(hashTypes []uint8) string { | ||
hashStrings := make([]string, len(hashTypes)) | ||
for i, hash := range hashTypes { | ||
hashStrings[i] = hashToString(hash) | ||
} | ||
return strings.Join(hashStrings, ", ") | ||
} | ||
|
||
func integersToString[T constraints.Integer](integers []T) string { | ||
integerStrings := make([]string, len(integers)) | ||
for i, hash := range integers { | ||
integerStrings[i] = fmt.Sprint(hash) | ||
} | ||
return strings.Join(integerStrings, ", ") | ||
} | ||
|
||
func wrapError(zone string, qClass, qType uint16, err error) error { | ||
return fmt.Errorf("for %s: %w", nameClassTypeToString(zone, qClass, qType), err) | ||
} | ||
|
||
func isOneOf[T comparable](value T, values ...T) bool { | ||
for _, v := range values { | ||
if value == v { | ||
return true | ||
} | ||
} | ||
return false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,251 @@ | ||
//go:build integration | ||
// +build integration | ||
|
||
package dnssec | ||
|
||
import ( | ||
"context" | ||
"net" | ||
"testing" | ||
"time" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/qdm12/dns/v2/internal/stateful" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func Test_Validate(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
request *dns.Msg | ||
errWrapped error | ||
errMessage string | ||
}{ | ||
// "exists_not_signed": { | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "test.github.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// "exists_signed": { | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "icann.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// "nodata_nsec3": { | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "icann.org.", Qtype: dns.TypeMD, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// "nxdomain_nsec3": { | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "xyz.icann.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// "nxdomain_nsec": { | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "x.cloudflare.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// "a_and_cname": { | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "sigok.ippacket.stream.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// // | ||
// // Special cases | ||
// // | ||
// "dnssec_failed_by_upstream": { | ||
// // One can also try rhybar.cz. | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "dnssec-failed.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// errWrapped: ErrRcodeBad, | ||
// errMessage: "running desired query: " + | ||
// "for dnssec-failed.org. IN A: " + | ||
// "bad response rcode: SERVFAIL", | ||
// }, | ||
"signed_answer_insecure_parent": { | ||
// The answer is a NODATA with an NSEC RRSet signed by whispersystems.org. | ||
// The parent zone whispersystems.org. has DNSKEYs (ZSK+KSK) but | ||
// no DS record, so it is therefore insecure and so is the answer. | ||
request: &dns.Msg{ | ||
Question: []dns.Question{ | ||
{Name: "textsecure-service.whispersystems.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
}, | ||
}, | ||
}, | ||
// "nxdomain_2_rrsigs_per_nsec": { | ||
// // There are two RRSIGs per NSEC RR, each with a | ||
// // different algorithm. This is to allow transitioning | ||
// // from one weaker/older algorithm to a stronger/newer one. | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "xyzzy14.sdsmt.edu.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
// "nodata_2_rrsigs_dnskey": { | ||
// // The DNSKEY RRSet of vip.icann.org. is signed by two RRSIGs, | ||
// // one validating against the ZSK of icann.org. and the other | ||
// // validating against the KSK of icann.org. This is valid although | ||
// // not very conventional. | ||
// request: &dns.Msg{ | ||
// Question: []dns.Question{ | ||
// {Name: "vip.icann.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}, | ||
// }, | ||
// }, | ||
// }, | ||
} | ||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCase.request.RecursionDesired = true | ||
testCase.request.Id = dns.Id() | ||
requestCopy := testCase.request.Copy() | ||
|
||
handler := newIntegTestHandler(t) | ||
|
||
response, err := Validate(testCase.request, handler) | ||
|
||
require.ErrorIs(t, err, testCase.errWrapped) | ||
|
||
var expectedResponse *dns.Msg | ||
if testCase.errWrapped != nil { | ||
assert.EqualError(t, err, testCase.errMessage) | ||
} else { // no error, fetch expected response | ||
statefulWriter := stateful.NewWriter() | ||
requestCopy.Id = dns.Id() | ||
handler.ServeDNS(statefulWriter, requestCopy) | ||
expectedResponse = statefulWriter.Response | ||
// DNSSEC does not do recursion for now | ||
expectedResponse.RecursionAvailable = false | ||
} | ||
|
||
assertResponsesEqual(t, expectedResponse, response) | ||
}) | ||
} | ||
} | ||
|
||
type integTestHandler struct { | ||
t *testing.T | ||
client *dns.Client | ||
dialer *net.Dialer | ||
} | ||
|
||
func newIntegTestHandler(t *testing.T) *integTestHandler { | ||
return &integTestHandler{ | ||
t: t, | ||
client: &dns.Client{}, | ||
dialer: &net.Dialer{}, | ||
} | ||
} | ||
|
||
func (h *integTestHandler) ServeDNS(w dns.ResponseWriter, request *dns.Msg) { | ||
request = request.Copy() | ||
|
||
deadline, ok := h.t.Deadline() | ||
if !ok { | ||
deadline = time.Now().Add(4 * time.Second) | ||
} | ||
ctx, cancel := context.WithDeadline(context.Background(), deadline) | ||
defer cancel() | ||
|
||
const maxTries = 3 | ||
success := false | ||
var response *dns.Msg | ||
for i := 0; i < maxTries; i++ { | ||
const timeout = time.Second | ||
ctx, cancel := context.WithTimeout(ctx, timeout) | ||
defer cancel() | ||
|
||
// Try a new UDP connection on each try | ||
netConn, err := h.dialer.DialContext(ctx, "udp", "1.1.1.1:53") | ||
require.NoError(h.t, err) | ||
dnsConn := &dns.Conn{Conn: netConn} | ||
|
||
response, _, err = h.client.ExchangeWithConnContext(ctx, request, dnsConn) | ||
if err != nil { | ||
_ = dnsConn.Close() | ||
h.t.Logf("try %d of %d: %s", i+1, maxTries, err) | ||
continue | ||
} | ||
|
||
err = dnsConn.Close() | ||
require.NoError(h.t, err) | ||
|
||
success = true | ||
break | ||
} | ||
|
||
if !success { | ||
h.t.Fatalf("could not communicate with DNS server after %d tries", maxTries) | ||
} | ||
|
||
if !response.Truncated { | ||
// Remove TTL fields from rrset | ||
for i := range response.Answer { | ||
response.Answer[i].Header().Ttl = 0 | ||
} | ||
|
||
_ = w.WriteMsg(response) | ||
return | ||
} | ||
|
||
// Retry with TCP | ||
netConn, err := h.dialer.DialContext(ctx, "tcp", "1.1.1.1:53") | ||
require.NoError(h.t, err) | ||
|
||
dnsConn := &dns.Conn{Conn: netConn} | ||
response, _, err = h.client.ExchangeWithConnContext(ctx, request, dnsConn) | ||
require.NoError(h.t, err) | ||
|
||
err = dnsConn.Close() | ||
require.NoError(h.t, err) | ||
|
||
_ = w.WriteMsg(response) | ||
} | ||
|
||
func assertResponsesEqual(t *testing.T, a, b *dns.Msg) { | ||
if a == nil { | ||
require.Nil(t, b) | ||
return | ||
} | ||
require.NotNil(t, b) | ||
|
||
// Remove TTL fields from answer and authority | ||
for i := range a.Answer { | ||
a.Answer[i].Header().Ttl = 0 | ||
} | ||
for i := range a.Ns { | ||
a.Ns[i].Header().Ttl = 0 | ||
} | ||
for i := range b.Answer { | ||
b.Answer[i].Header().Ttl = 0 | ||
} | ||
for i := range b.Ns { | ||
b.Ns[i].Header().Ttl = 0 | ||
} | ||
|
||
a.Id = 0 | ||
b.Id = 0 | ||
|
||
assert.Equal(t, a, b) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
// Note: validateNoData works also for the qtype DS since | ||
// the implementations of nsec3ValidateNoData and | ||
// nsecValidateNoData take care of redirecting to the | ||
// DS specific validation functions, but preferably use | ||
// validateNoDataDS for the qtype DS. | ||
func validateNoData(qname string, qtype uint16, | ||
authoritySection []dnssecRRSet, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) (err error) { | ||
err = verifyRRSetsRRSig(nil, authoritySection, keyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying RRSIGs: %w", err) | ||
} | ||
|
||
nsec3RRs, wildcard := extractNSEC3s(authoritySection) | ||
if len(nsec3RRs) > 0 { | ||
nsec3RRs, err = nsec3InitialChecks(nsec3RRs) | ||
if err != nil { | ||
return fmt.Errorf("initial NSEC3 checks: %w", err) | ||
} else if wildcard { | ||
return nsec3ValidateNoDataWildcard(qname, qtype, nsec3RRs) | ||
} | ||
return nsec3ValidateNoData(qname, qtype, nsec3RRs) | ||
} | ||
|
||
nsecRRs := extractNSECs(authoritySection) | ||
if len(nsecRRs) > 0 { | ||
return nsecValidateNoData(qname, qtype, nsecRRs) | ||
} | ||
|
||
return fmt.Errorf("verifying no data for %s: %w: "+ | ||
"no NSEC or NSEC3 record found", | ||
nameTypeToString(qname, qtype), ErrBogus) | ||
} | ||
|
||
func validateNoDataDS(qname string, | ||
authoritySection []dnssecRRSet, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) (err error) { | ||
err = verifyRRSetsRRSig(nil, authoritySection, keyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying RRSIGs: %w", err) | ||
} | ||
|
||
nsec3RRs, wildcard := extractNSEC3s(authoritySection) | ||
if len(nsec3RRs) > 0 { | ||
nsec3RRs, err = nsec3InitialChecks(nsec3RRs) | ||
if err != nil { | ||
return fmt.Errorf("initial NSEC3 checks: %w", err) | ||
} else if wildcard { | ||
return nsec3ValidateNoDataWildcard(qname, dns.TypeDS, nsec3RRs) | ||
} | ||
return nsec3ValidateNoDataDS(qname, nsec3RRs) | ||
} | ||
|
||
nsecRRs := extractNSECs(authoritySection) | ||
if len(nsecRRs) > 0 { | ||
return nsecValidateNoDataDS(qname, nsecRRs) | ||
} | ||
|
||
return fmt.Errorf("verifying no DS data for %s: %w: "+ | ||
"no NSEC or NSEC3 record found", | ||
qname, ErrBogus) | ||
} | ||
|
||
// See https://datatracker.ietf.org/doc/html/rfc5155#section-8.6 | ||
func verifyNoDataNsecxTypesDS(nsecVariant string, | ||
nsecTypes []uint16) (err error) { | ||
for _, nsecType := range nsecTypes { | ||
switch nsecType { | ||
case dns.TypeSOA: | ||
return fmt.Errorf("%w: %s contains SOA type"+ | ||
" so is from the child zone and not the parent zone", | ||
ErrBogus, nsecVariant) | ||
case dns.TypeDS: | ||
return fmt.Errorf("%w: %s contains DS type", ErrBogus, nsecVariant) | ||
case dns.TypeCNAME: | ||
return fmt.Errorf("%w: %s contains CNAME type", | ||
ErrBogus, nsecVariant) | ||
} | ||
} | ||
|
||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
func mustRRToNSEC(rr dns.RR) (nsec *dns.NSEC) { | ||
nsec, ok := rr.(*dns.NSEC) | ||
if !ok { | ||
panic(fmt.Sprintf("RR is of type %T and not of type *dns.NSEC", rr)) | ||
} | ||
return nsec | ||
} | ||
|
||
// extractNSECs returns the NSEC RRs found in the NSEC | ||
// signed RRSet from the slice of signed RRSets. | ||
func extractNSECs(rrSets []dnssecRRSet) (nsecs []dns.RR) { | ||
for _, rrSet := range rrSets { | ||
if rrSet.qtype() == dns.TypeNSEC { | ||
return rrSet.rrSet | ||
} | ||
} | ||
return nil | ||
} | ||
|
||
func nsecValidateNxDomain(qname string, nsecRRSet []dns.RR) (err error) { | ||
for _, nsecRR := range nsecRRSet { | ||
nsec := mustRRToNSEC(nsecRR) | ||
if nsecCoversZone(qname, nsec.Hdr.Name, nsec.NextDomain) { | ||
return nil | ||
} | ||
} | ||
|
||
return fmt.Errorf("for qname %s: %w: "+ | ||
"no NSEC covering qname found", | ||
qname, ErrBogus) | ||
} | ||
|
||
func nsecValidateNoData(qname string, qType uint16, | ||
nsecRRSet []dns.RR) (err error) { | ||
if qType == dns.TypeDS { | ||
return nsecValidateNoDataDS(qname, nsecRRSet) | ||
} | ||
|
||
var qnameMatchingNSEC *dns.NSEC | ||
for _, nsecRR := range nsecRRSet { | ||
nsec := mustRRToNSEC(nsecRR) | ||
if nsecMatchesQname(nsec, qname) { | ||
qnameMatchingNSEC = nsec | ||
break | ||
} | ||
} | ||
|
||
if qnameMatchingNSEC == nil { | ||
return fmt.Errorf("for zone %s and type %s: %w: "+ | ||
"no NSEC matching qname found", | ||
qname, dns.TypeToString[qType], ErrBogus) | ||
} | ||
|
||
for _, nsecType := range qnameMatchingNSEC.TypeBitMap { | ||
switch nsecType { | ||
case qType: | ||
return fmt.Errorf("for qname %s and type %s: %w: "+ | ||
"qtype contained in NSEC", | ||
qname, dns.TypeToString[qType], ErrBogus) | ||
case dns.TypeCNAME: // TODO check this is invalid | ||
return fmt.Errorf("for qname %s and type %s: %w: "+ | ||
"CNAME contained in NSEC", | ||
qname, dns.TypeToString[qType], ErrBogus) | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func nsecValidateNoDataDS(qname string, nsecRRSet []dns.RR) (err error) { | ||
var qnameMatchingNSEC *dns.NSEC | ||
for _, nsecRR := range nsecRRSet { | ||
nsec := mustRRToNSEC(nsecRR) | ||
if nsecMatchesQname(nsec, qname) { | ||
qnameMatchingNSEC = nsec | ||
break | ||
} | ||
} | ||
|
||
if qnameMatchingNSEC == nil { | ||
return fmt.Errorf("for qname %s: %w: "+ | ||
"no NSEC matching qname found", | ||
qname, ErrBogus) | ||
} | ||
|
||
err = verifyNoDataNsecxTypesDS("NSEC", qnameMatchingNSEC.TypeBitMap) | ||
if err != nil { | ||
return fmt.Errorf("for qname %s: %w", | ||
qname, err) | ||
} | ||
return nil | ||
} | ||
|
||
// nsecMatchesQname returns true if the NSEC owner name is equal | ||
// to the qname or if the NSEC owner name is a wildcard name parent | ||
// of qname. | ||
func nsecMatchesQname(nsec *dns.NSEC, qname string) bool { | ||
return nsec.Hdr.Name == qname || (strings.HasPrefix(nsec.Hdr.Name, "*.") && | ||
dns.IsSubDomain(nsec.Hdr.Name[2:], qname)) | ||
} | ||
|
||
// nsecCoversZone returns true if the zone is within the OPEN interval | ||
// delimited by the nsecOwner and the nsecNext FQDNs given. | ||
// TODO improve inspiring from | ||
// https://github.com/NLnetLabs/unbound/blob/master/util/data/dname.c#L802 | ||
func nsecCoversZone(zone, nsecOwner, nsecNext string) (ok bool) { | ||
if zone == nsecOwner || zone == nsecNext { | ||
return false | ||
} | ||
|
||
zoneLabels := dns.SplitDomainName(zone) | ||
nsecOwnerLabels := dns.SplitDomainName(nsecOwner) | ||
|
||
if len(zoneLabels) < len(nsecOwnerLabels) { | ||
// zone is shorter than NSEC owner, so it cannot be covered | ||
return false | ||
} | ||
|
||
for i := range nsecOwnerLabels { | ||
zoneLabel := zoneLabels[len(zoneLabels)-1-i] | ||
nsecOwnerLabel := nsecOwnerLabels[len(nsecOwnerLabels)-1-i] | ||
if zoneLabel < nsecOwnerLabel { | ||
return false | ||
} | ||
} | ||
|
||
nsecNextLabels := dns.SplitDomainName(nsecNext) | ||
if len(zoneLabels) < len(nsecNextLabels) { | ||
// zone is shorter than NSEC next, so it cannot be covered | ||
return false | ||
} | ||
|
||
minLabelsCount := min(len(zoneLabels), len(nsecNextLabels)) | ||
for i := 0; i < minLabelsCount; i++ { | ||
zoneLabel := zoneLabels[len(zoneLabels)-1-i] | ||
nsecNextLabel := nsecNextLabels[len(nsecNextLabels)-1-i] | ||
if zoneLabel > nsecNextLabel { | ||
return false | ||
} | ||
} | ||
|
||
// Zone and next domain have the same labels for the first | ||
// minLabelsCount labels, and zone != next, so zone is within | ||
// the interval delimited by owner and next. | ||
return true | ||
// TODO wildcard handling? | ||
} |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package dnssec | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func Test_getNextCloser(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
qname string | ||
closestEncloser string | ||
nextCloser string | ||
}{ | ||
"case1": { | ||
qname: "a.b.example.com.", | ||
closestEncloser: "example.com.", | ||
nextCloser: "b.example.com.", | ||
}, | ||
"q_name_is_next_closer": { | ||
qname: "a.example.com.", | ||
closestEncloser: "example.com.", | ||
nextCloser: "a.example.com.", | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
nextCloser := getNextCloser(testCase.qname, testCase.closestEncloser) | ||
|
||
assert.Equal(t, testCase.nextCloser, nextCloser) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
package dnssec | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func Test_nsecCover(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
zone string | ||
nsecOwner string | ||
nsecNext string | ||
ok bool | ||
}{ | ||
"zone_shorter_than_owner": { | ||
zone: "example.com.", | ||
nsecOwner: "a.example.com.", | ||
}, | ||
"zone_before_owner": { | ||
zone: "a.example.com.", | ||
nsecOwner: "b.example.com.", | ||
}, | ||
"zone_not_subdomain_of_owner": { | ||
zone: "a.a.example.com.", | ||
nsecOwner: "b.example.com.", | ||
}, | ||
"malformed_longer_next": { | ||
zone: "b.example.com.", | ||
nsecOwner: "a.example.com.", | ||
nsecNext: "c.c.example.com.", | ||
}, | ||
"zone_equal_to_next": { | ||
zone: "b.example.com.", | ||
nsecOwner: "a.example.com.", | ||
nsecNext: "b.example.com.", | ||
}, | ||
"zone_after_next": { | ||
zone: "c.example.com.", | ||
nsecOwner: "a.example.com.", | ||
nsecNext: "b.example.com.", | ||
}, | ||
"zone_not_subdomain_of_next": { | ||
zone: "b.b.example.com.", | ||
nsecOwner: "a.example.com.", | ||
nsecNext: "c.example.com.", | ||
ok: true, | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
ok := nsecCoversZone(testCase.zone, testCase.nsecOwner, testCase.nsecNext) | ||
|
||
assert.Equal(t, testCase.ok, ok) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
var ( | ||
ErrRRSigWildcardUnexpected = errors.New("RRSIG for a wildcard is unexpected") | ||
) | ||
|
||
func validateNxDomain(qname string, authoritySection []dnssecRRSet, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) (err error) { | ||
err = verifyRRSetsRRSig(nil, authoritySection, keyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying RRSIGs: %w", err) | ||
} | ||
|
||
nsec3RRs, wildcard := extractNSEC3s(authoritySection) | ||
if wildcard { | ||
return fmt.Errorf("for NXDOMAIN response for %s: NSEC3: %w", | ||
qname, ErrRRSigWildcardUnexpected) | ||
} else if len(nsec3RRs) > 0 { | ||
nsec3RRs, err = nsec3InitialChecks(nsec3RRs) | ||
if err != nil { | ||
return fmt.Errorf("initial NSEC3 checks: %w", err) | ||
} | ||
return nsec3ValidateNxDomain(qname, nsec3RRs) | ||
} | ||
|
||
nsecRRs := extractNSECs(authoritySection) | ||
if len(nsecRRs) > 0 { | ||
return nsecValidateNxDomain(qname, nsecRRs) | ||
} | ||
|
||
return fmt.Errorf("for %s: %w: no NSEC or NSEC3 record found", | ||
qname, ErrBogus) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/qdm12/dns/v2/internal/stateful" | ||
) | ||
|
||
var ( | ||
ErrRcodeBad = errors.New("bad response rcode") | ||
) | ||
|
||
func queryRRSets(handler dns.Handler, zone string, | ||
qClass, qType uint16) (response dnssecResponse, err error) { | ||
request := newEDNSRequest(zone, qClass, qType) | ||
|
||
statefulWriter := stateful.NewWriter() | ||
handler.ServeDNS(statefulWriter, request) | ||
dnsResponse := statefulWriter.Response | ||
response.rcode = dnsResponse.Rcode | ||
|
||
switch { | ||
case dnsResponse.Rcode == dns.RcodeSuccess && len(dnsResponse.Answer) > 0: | ||
// Success and we have at least one answer RR. | ||
response.answerRRSets, err = groupRRs(dnsResponse.Answer) | ||
if err != nil { | ||
return dnssecResponse{}, fmt.Errorf( | ||
"grouping answer RRSets for %s: %w", | ||
nameClassTypeToString(zone, qClass, qType), err) | ||
} | ||
|
||
if !response.isSigned() { | ||
// We have all unsigned answers | ||
return response, nil | ||
} | ||
|
||
// Every RRSet has at least one RRSIG associated with it. | ||
// The caller should then verify the RRSIGs and MAY need | ||
// NSEC or NSEC3 RRSets from the authority section to verify | ||
// it does not match a wildcard. | ||
response.authorityRRSets, err = groupRRs(dnsResponse.Ns) | ||
if err != nil { | ||
return dnssecResponse{}, fmt.Errorf( | ||
"grouping authority RRSets for %s: %w", | ||
nameClassTypeToString(zone, qClass, qType), err) | ||
} | ||
|
||
return response, nil | ||
case dnsResponse.Rcode == dns.RcodeSuccess && len(dnsResponse.Answer) == 0, | ||
dnsResponse.Rcode == dns.RcodeNameError: | ||
// NXDOMAIN or NODATA response, we need to verify the negative | ||
// response with the query authority section NSEC/NSEC3 RRSet | ||
// or verify the zone is insecure. | ||
// If the zone is insecure, the caller verifies the zone is | ||
// insecure using the NSEC/NSEC3 records of the authority | ||
// section of the DS query for that zone, or any first of | ||
// its parent zone with an NSEC/NSEC3 record for that zone, | ||
// walking towards the root zone. | ||
// There is no difference in handling if we received a NODATA | ||
// or NXDOMAIN response. | ||
|
||
if len(dnsResponse.Ns) == 0 { | ||
// No authority RR so there cannot be any NSEC/NSEC3 RRSet, | ||
// the zone is thus insecure. | ||
return response, nil | ||
} | ||
|
||
response.authorityRRSets, err = groupRRs(dnsResponse.Ns) | ||
if err != nil { | ||
return dnssecResponse{}, fmt.Errorf( | ||
"grouping authority RRSets for %s: %w", | ||
nameClassTypeToString(zone, qClass, qType), err) | ||
} | ||
|
||
// TODO make sure we ignore nsec without rrsig | ||
return response, nil | ||
default: // other error | ||
// If the response Rcode is dns.RcodeServerFailure, | ||
// this may mean DNSSEC validation failed on the upstream server. | ||
// https://www.ietf.org/rfc/rfc4033.txt | ||
// This specification only defines how security-aware name servers can | ||
// signal non-validating stub resolvers that data was found to be bogus | ||
// (using RCODE=2, "Server Failure"; see [RFC4035]). | ||
return dnssecResponse{}, fmt.Errorf( | ||
"for %s: %w: %s", | ||
nameClassTypeToString(zone, qClass, qType), | ||
ErrRcodeBad, dns.RcodeToString[dnsResponse.Rcode]) | ||
} | ||
} | ||
|
||
var ( | ||
ErrRRSetSignedAndUnsigned = errors.New("mix of signed and unsigned RRSets") | ||
ErrRRSigForNoRRSet = errors.New("RRSIG for no RRSet") | ||
) | ||
|
||
// groupRRs groups RRs by type AND owner AND class, returning a slice | ||
// of 'DNSSEC RRSets' where each contains at least one RR, | ||
// and zero or one RRSIG signature. | ||
// Regarding the RRSig validity requirements listed in | ||
// https://datatracker.ietf.org/doc/html/rfc4035#section-5.3.1 | ||
// | ||
// The following requirements are fullfiled by design: | ||
// - The RRSIG RR and the RRset MUST have the same owner name and the | ||
// same class | ||
// - The RRSIG RR's Type Covered field MUST equal the RRset's type. | ||
// | ||
// And the function returns an error for the following unmet requirements: | ||
// - The RRSIG RR's Signer's Name field MUST be the name of the zone | ||
// that contains the RRset. | ||
// - The number of labels in the RRset owner name MUST be greater than | ||
// or equal to the value in the RRSIG RR's Labels field. | ||
// | ||
// The following requirements are enforced at a later stage: | ||
// - The validator's notion of the current time MUST be less than or | ||
// equal to the time listed in the RRSIG RR's Expiration field. | ||
func groupRRs(rrs []dns.RR) (dnssecRRSets []dnssecRRSet, err error) { | ||
// For well formed DNSSEC DNS answers, there should be at most | ||
// N/2 signed RRSets (grouped by qname-qtype-qclass) where N is | ||
// the number of total answers. | ||
maxRRSets := len(rrs) / 2 //nolint:gomnd | ||
dnssecRRSets = make([]dnssecRRSet, 0, maxRRSets) | ||
type typeZoneKey struct { | ||
rrType uint16 | ||
owner string | ||
class uint16 | ||
} | ||
typeZoneToIndex := make(map[typeZoneKey]int, maxRRSets) | ||
|
||
// Used to check we have all signed RRSets or all | ||
// unsigned RRSets. | ||
unsignedRRsCount := 0 | ||
for _, rr := range rrs { | ||
header := rr.Header() | ||
typeZoneKey := typeZoneKey{ | ||
owner: header.Name, | ||
class: header.Class, | ||
} | ||
|
||
rrType := header.Rrtype | ||
if rrType == dns.TypeRRSIG { | ||
rrsig := mustRRToRRSig(rr) | ||
err = rrsigInitialChecks(rrsig) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
typeZoneKey.rrType = rrsig.TypeCovered | ||
i, ok := typeZoneToIndex[typeZoneKey] | ||
if !ok { | ||
dnssecRRSets = append(dnssecRRSets, dnssecRRSet{}) | ||
i = len(dnssecRRSets) - 1 | ||
typeZoneToIndex[typeZoneKey] = i | ||
} | ||
|
||
if len(dnssecRRSets[i].rrSigs) == 0 { | ||
unsignedRRsCount -= len(dnssecRRSets[i].rrSet) | ||
} | ||
dnssecRRSets[i].rrSigs = append(dnssecRRSets[i].rrSigs, rrsig) | ||
continue | ||
} | ||
|
||
typeZoneKey.rrType = rrType | ||
i, ok := typeZoneToIndex[typeZoneKey] | ||
if !ok { | ||
dnssecRRSets = append(dnssecRRSets, dnssecRRSet{}) | ||
i = len(dnssecRRSets) - 1 | ||
typeZoneToIndex[typeZoneKey] = i | ||
} | ||
dnssecRRSets[i].rrSet = append(dnssecRRSets[i].rrSet, rr) | ||
if len(dnssecRRSets[i].rrSigs) == 0 { | ||
unsignedRRsCount++ | ||
} | ||
} | ||
|
||
// Verify all RRSets are either signed or unsigned. | ||
switch unsignedRRsCount { | ||
case 0: | ||
case len(rrs): | ||
default: | ||
signedRRsCount := len(rrs) - unsignedRRsCount | ||
return nil, fmt.Errorf("%w: %d signed RRs and %d unsigned RRs", | ||
ErrRRSetSignedAndUnsigned, signedRRsCount, unsignedRRsCount) | ||
} | ||
|
||
// Verify built DNSSEC RRSets are well formed. | ||
for _, dnssecRRSet := range dnssecRRSets { | ||
if len(dnssecRRSet.rrSigs) > 0 && len(dnssecRRSet.rrSet) == 0 { | ||
return nil, fmt.Errorf("for RRSet %s %s: %w", | ||
dnssecRRSet.rrSigs[0].Hdr.Name, | ||
dns.TypeToString[dnssecRRSet.rrSigs[0].TypeCovered], | ||
ErrRRSigForNoRRSet) | ||
} | ||
} | ||
|
||
return dnssecRRSets, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
package dnssec | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func Test_groupRRs(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
rrs []dns.RR | ||
dnssecRRSets []dnssecRRSet | ||
errWrapped error | ||
errMessage string | ||
}{ | ||
"no_rrs": { | ||
dnssecRRSets: []dnssecRRSet{}, | ||
}, | ||
"bad_single_rrsig_answer": { | ||
rrs: []dns.RR{ | ||
newEmptyRRSig(dns.TypeA), | ||
}, | ||
errWrapped: ErrRRSigForNoRRSet, | ||
errMessage: "for RRSet example.com. A: RRSIG for no RRSet", | ||
}, | ||
"bad_rrsig_for_no_rrset": { | ||
rrs: []dns.RR{ | ||
newEmptyAAAA(), | ||
newEmptyRRSig(dns.TypeAAAA), | ||
newEmptyRRSig(dns.TypeA), // bad one | ||
}, | ||
errWrapped: ErrRRSigForNoRRSet, | ||
errMessage: "for RRSet example.com. A: RRSIG for no RRSet", | ||
}, | ||
"multiple_rrsig_for_same_type": { | ||
rrs: []dns.RR{ | ||
newEmptyRRSig(dns.TypeA), | ||
newEmptyA(), | ||
newEmptyRRSig(dns.TypeA), | ||
}, | ||
dnssecRRSets: []dnssecRRSet{ | ||
{ | ||
rrSet: []dns.RR{ | ||
newEmptyA(), | ||
}, | ||
rrSigs: []*dns.RRSIG{ | ||
newEmptyRRSig(dns.TypeA), | ||
newEmptyRRSig(dns.TypeA), | ||
}, | ||
}, | ||
}, | ||
}, | ||
"bad_signed_and_not_signed_rrsets": { | ||
rrs: []dns.RR{ | ||
newEmptyRRSig(dns.TypeA), | ||
newEmptyAAAA(), | ||
newEmptyA(), | ||
}, | ||
errWrapped: ErrRRSetSignedAndUnsigned, | ||
errMessage: "mix of signed and unsigned RRSets: 2 signed RRs and 1 unsigned RRs", | ||
}, | ||
"signed_rrsets": { | ||
rrs: []dns.RR{ | ||
newEmptyRRSig(dns.TypeA), | ||
newEmptyA(), | ||
newEmptyAAAA(), | ||
newEmptyRRSig(dns.TypeAAAA), | ||
}, | ||
dnssecRRSets: []dnssecRRSet{ | ||
{ | ||
rrSigs: []*dns.RRSIG{newEmptyRRSig(dns.TypeA)}, | ||
rrSet: []dns.RR{ | ||
newEmptyA(), | ||
}, | ||
}, | ||
{ | ||
rrSigs: []*dns.RRSIG{newEmptyRRSig(dns.TypeAAAA)}, | ||
rrSet: []dns.RR{ | ||
newEmptyAAAA(), | ||
}, | ||
}, | ||
}, | ||
}, | ||
"not_signed_rrsets": { | ||
rrs: []dns.RR{ | ||
newEmptyA(), | ||
newEmptyAAAA(), | ||
}, | ||
dnssecRRSets: []dnssecRRSet{ | ||
{ | ||
rrSet: []dns.RR{ | ||
newEmptyA(), | ||
}, | ||
}, | ||
{ | ||
rrSet: []dns.RR{ | ||
newEmptyAAAA(), | ||
}, | ||
}, | ||
}, | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
dnssecRRSets, err := groupRRs(testCase.rrs) | ||
|
||
assert.Equal(t, testCase.dnssecRRSets, dnssecRRSets) | ||
assert.ErrorIs(t, err, testCase.errWrapped) | ||
if testCase.errWrapped != nil { | ||
assert.EqualError(t, err, testCase.errMessage) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func newEmptyRRSig(typeCovered uint16) *dns.RRSIG { | ||
return &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
Rrtype: dns.TypeRRSIG, | ||
}, | ||
TypeCovered: typeCovered, | ||
SignerName: "example.com.", | ||
} | ||
} | ||
|
||
func newEmptyA() *dns.A { | ||
return &dns.A{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
Rrtype: dns.TypeA, | ||
}, | ||
} | ||
} | ||
|
||
func newEmptyAAAA() *dns.AAAA { | ||
return &dns.AAAA{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
Rrtype: dns.TypeAAAA, | ||
}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
# DNSSEC validator | ||
|
||
This package implements a DNSSEC validator middleware for a DNS forwarding server. | ||
It performs all queries through the next DNS handler middleware.... TODO | ||
|
||
## Comments | ||
|
||
Comments are aimed to be minimal and clear code is preferred over comments. | ||
However, there are a few references to specific sections of IETF RFCs especially when it comes to function comments. | ||
|
||
## Terminology used | ||
|
||
Teminology used in the code aims at being as close as possible to IETF RFCs. | ||
|
||
When this is unclear, there are a few specific rules used, for example: | ||
|
||
- Using `validate` or `verify`, see the `validate vs. verify` in [RFC4949](https://datatracker.ietf.org/doc/html/rfc4949) | ||
|
||
## Documentation used | ||
|
||
### IETF RFCs | ||
|
||
- [RFC4033](https://datatracker.ietf.org/doc/html/rfc4033) | ||
- [RFC5155 on NSEC3](https://datatracker.ietf.org/doc/html/rfc5155) | ||
- [RFC8624 on DNSKEY algorithms](https://datatracker.ietf.org/doc/html/rfc8624#section-3.1) | ||
|
||
### Blog posts | ||
|
||
<https://blog.nlnetlabs.nl/the-peculiar-case-of-nsec-processing-using-expanded-wildcard-records/> | ||
<https://wander.science/projects/dns/dnssec-resolver-test/> | ||
|
||
### Videos | ||
|
||
- [DNSSEC Series #5 Record types, keys, signatures and NSEC](https://www.youtube.com/watch?v=FGs9kbdgMXE&t=2825s) | ||
|
||
## Tools used to help debugging | ||
|
||
- [DNSViz](https://dnsviz.net/) | ||
- [DNSSEC Analyzer](https://dnssec-analyzer.verisignlabs.com) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
type dnssecResponse struct { | ||
answerRRSets []dnssecRRSet | ||
authorityRRSets []dnssecRRSet | ||
rcode int | ||
} | ||
|
||
func (d dnssecResponse) isNXDomain() bool { | ||
return d.rcode == dns.RcodeNameError | ||
} | ||
|
||
func (d dnssecResponse) isNoData() bool { | ||
return d.rcode == dns.RcodeSuccess && len(d.answerRRSets) == 0 | ||
} | ||
|
||
func (d dnssecResponse) isSigned() bool { | ||
// Note a slice of DNSSEC RRSets is either all signed or all unsigned. | ||
switch { | ||
case len(d.answerRRSets) > 0 && len(d.answerRRSets[0].rrSigs) == 0, | ||
len(d.authorityRRSets) > 0 && len(d.authorityRRSets[0].rrSigs) == 0, | ||
len(d.answerRRSets) == 0 && len(d.authorityRRSets) == 0: | ||
return false | ||
default: | ||
return true | ||
} | ||
} | ||
|
||
func (d dnssecResponse) onlyAnswerRRSet() (rrSet []dns.RR) { | ||
if len(d.answerRRSets) != 1 { | ||
panic(fmt.Sprintf("DNSSEC response has %d answer RRSets instead of 1", | ||
len(d.answerRRSets))) | ||
} | ||
return d.answerRRSets[0].rrSet | ||
} | ||
|
||
func (d dnssecResponse) onlyAnswerRRSigs() (rrSigs []*dns.RRSIG) { | ||
if len(d.answerRRSets) != 1 { | ||
panic(fmt.Sprintf("DNSSEC response has %d answer RRSets instead of 1", | ||
len(d.answerRRSets))) | ||
} | ||
return d.answerRRSets[0].rrSigs | ||
} | ||
|
||
func (d dnssecResponse) ToDNSMsg(request *dns.Msg) (response *dns.Msg) { | ||
response = new(dns.Msg) | ||
response.SetRcode(request, d.rcode) | ||
var ignoreTypes []uint16 | ||
if !isRequestAskingForDNSSEC(request) { | ||
ignoreTypes = []uint16{dns.TypeNSEC, dns.TypeNSEC3, dns.TypeRRSIG} | ||
} | ||
response.Answer = dnssecRRSetsToRRs(d.answerRRSets, ignoreTypes...) | ||
response.Ns = dnssecRRSetsToRRs(d.authorityRRSets, ignoreTypes...) | ||
return response | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
// dnssecRRSet is a possibly signed RRSet for a certain | ||
// owner, type and class, containing at least one or more | ||
// RRs and zero or more RRSigs. | ||
// If the RRSet is unsigned, the rrSigs field is a slice | ||
// of length 0. | ||
type dnssecRRSet struct { | ||
// rrSigs is the slice of RRSIGs for the RRSet. | ||
// There can be more than one RRSIG, for example: | ||
// dig +dnssec -t A xyzzy14.sdsmt.edu. @1.1.1.1 | ||
// returns 2 RRSIGs for the SOA authority section RRSet. | ||
rrSigs []*dns.RRSIG | ||
// rrSet cannot be empty. | ||
rrSet []dns.RR | ||
} | ||
|
||
func (d dnssecRRSet) qtype() uint16 { | ||
return d.rrSet[0].Header().Rrtype | ||
} | ||
|
||
func (d dnssecRRSet) ownerAndType() string { | ||
return d.rrSet[0].Header().Name + " " + | ||
dns.TypeToString[d.rrSet[0].Header().Rrtype] | ||
} | ||
|
||
func dnssecRRSetsToRRs(rrSets []dnssecRRSet, ignoreTypes ...uint16) (rrs []dns.RR) { | ||
if len(rrSets) == 0 { | ||
return nil | ||
} | ||
|
||
ignoreTypesMap := make(map[uint16]struct{}, len(ignoreTypes)) | ||
for _, ignoreType := range ignoreTypes { | ||
ignoreTypesMap[ignoreType] = struct{}{} | ||
} | ||
|
||
minRRSetSize := len(rrSets) // 1 RR per owner, type and class | ||
rrs = make([]dns.RR, 0, minRRSetSize) | ||
for _, rrSet := range rrSets { | ||
for _, rr := range rrSet.rrSet { | ||
rrType := rr.Header().Rrtype | ||
_, ignore := ignoreTypesMap[rrType] | ||
if ignore { | ||
continue | ||
} | ||
rrs = append(rrs, rr) | ||
} | ||
|
||
_, rrSigIgnored := ignoreTypesMap[dns.TypeRRSIG] | ||
if rrSigIgnored { | ||
continue | ||
} | ||
|
||
for _, rrSig := range rrSet.rrSigs { | ||
_, ignored := ignoreTypesMap[rrSig.TypeCovered] | ||
if ignored { | ||
continue | ||
} | ||
rrs = append(rrs, rrSig) | ||
} | ||
} | ||
|
||
return rrs | ||
} | ||
|
||
var ( | ||
ErrRRSetsMissing = errors.New("no RRSet") | ||
ErrRRSetsMultiple = errors.New("multiple RRSets") | ||
ErrRRSetTypeUnexpected = errors.New("RRSet type unexpected") | ||
) | ||
|
||
func dnssecRRSetsIsSingleOfType(rrSets []dnssecRRSet, qType uint16) (err error) { | ||
switch { | ||
case len(rrSets) == 0: | ||
return fmt.Errorf("%w", ErrRRSetsMissing) | ||
case len(rrSets) == 1: | ||
default: | ||
return fmt.Errorf("%w: received %d RRSets instead of 1", | ||
ErrRRSetsMultiple, len(rrSets)) | ||
} | ||
|
||
rrSetType := rrSets[0].qtype() | ||
if rrSetType != qType { | ||
return fmt.Errorf("%w: received %s RRSet instead of %s", | ||
ErrRRSetTypeUnexpected, dns.TypeToString[rrSetType], | ||
dns.TypeToString[qType]) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func removeFromRRSet(rrSet []dns.RR, typesToRemove ...uint16) (filtered []dns.RR) { | ||
if len(rrSet) == 0 { | ||
return nil | ||
} | ||
|
||
filtered = make([]dns.RR, 0, len(rrSet)) | ||
for _, rr := range rrSet { | ||
rrType := rr.Header().Rrtype | ||
for _, rrTypeToRemove := range typesToRemove { | ||
if rrType == rrTypeToRemove { | ||
continue | ||
} | ||
} | ||
filtered = append(filtered, rr) | ||
} | ||
return filtered | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"sort" | ||
"time" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
func mustRRToRRSig(rr dns.RR) (rrSig *dns.RRSIG) { | ||
rrSig, ok := rr.(*dns.RRSIG) | ||
if !ok { | ||
panic(fmt.Sprintf("RR is of type %T and not of type *dns.RRSIG", rr)) | ||
} | ||
return rrSig | ||
} | ||
|
||
func rrSigToOwnerTypeCovered(rrSig *dns.RRSIG) (ownerTypeCovered string) { | ||
return fmt.Sprintf("RRSIG for owner %s and type %s", | ||
rrSig.Header().Name, dns.TypeToString[rrSig.TypeCovered]) | ||
} | ||
|
||
// isRRSigForWildcard returns true if the RRSIG is for a wildcard. | ||
// This is detected by checking if the number of labels in the RRSIG | ||
// owner name is less than the number of labels in the RRSig owner name. | ||
// See https://datatracker.ietf.org/doc/html/rfc7129#section-5.3 | ||
func isRRSigForWildcard(rrSig *dns.RRSIG) bool { | ||
if rrSig == nil { | ||
return false | ||
} | ||
ownerLabelsCount := uint8(dns.CountLabel(rrSig.Header().Name)) | ||
return rrSig.Labels < ownerLabelsCount | ||
} | ||
|
||
var ( | ||
ErrRRSigLabels = errors.New("RRSIG labels greater than owner labels") | ||
) | ||
|
||
// See https://datatracker.ietf.org/doc/html/rfc4035#section-5.3.1 | ||
func rrsigInitialChecks(rrsig *dns.RRSIG) (err error) { | ||
rrSetOwner := rrsig.Hdr.Name | ||
|
||
err = rrSigCheckSignerName(rrsig) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if int(rrsig.Labels) > dns.CountLabel(rrSetOwner) { | ||
// The number of labels in the RRset owner name MUST be greater than | ||
// or equal to the value in the RRSIG RR's Labels field. | ||
return fmt.Errorf("for %s: %w: RRSig labels field is %d and owner is %d labels", | ||
rrSigToOwnerTypeCovered(rrsig), ErrRRSigLabels, | ||
rrsig.Labels, dns.CountLabel(rrSetOwner)) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// For wildcard considerations in positive responses, see: | ||
// - https://datatracker.ietf.org/doc/html/rfc2535#section-5.3 | ||
// - https://datatracker.ietf.org/doc/html/rfc4035#section-5.3.4 | ||
func verifyRRSetsRRSig(answerRRSets, authorityRRSets []dnssecRRSet, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) (err error) { | ||
var wildcardOwners []string | ||
for _, signedRRSet := range answerRRSets { | ||
err = verifyRRSetRRSigs(signedRRSet.rrSet, | ||
signedRRSet.rrSigs, keyTagToDNSKey) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
for _, rrSig := range signedRRSet.rrSigs { | ||
if isRRSigForWildcard(rrSig) { | ||
wildcardOwners = append(wildcardOwners, rrSig.Hdr.Name) | ||
} | ||
} | ||
} | ||
|
||
if len(answerRRSets) > 0 && len(wildcardOwners) == 0 { | ||
// No answer section RRSIG for a wildcard found. | ||
return nil | ||
} | ||
|
||
// The authority section is verified only if: | ||
// - there is no answer (NODATA or NXDOMAIN) | ||
// - there is an answer and there is at least one wildcard owner | ||
for _, signedRRSet := range authorityRRSets { | ||
err = verifyRRSetRRSigs(signedRRSet.rrSet, | ||
signedRRSet.rrSigs, keyTagToDNSKey) | ||
if err != nil { | ||
return err | ||
} | ||
} | ||
|
||
// TODO check wildcard for positive responses | ||
|
||
return nil | ||
} | ||
|
||
func verifyRRSetRRSigs(rrSet []dns.RR, rrSigs []*dns.RRSIG, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) ( | ||
err error) { | ||
if len(rrSet) == 0 || len(rrSigs) == 0 { | ||
panic("no rrs or rrsigs") | ||
} | ||
|
||
if len(rrSigs) == 1 { | ||
return verifyRRSetRRSig(rrSet, rrSigs[0], keyTagToDNSKey) | ||
} | ||
|
||
// Multiple RRSIGs for the same RRSet, sort them by algorithm preference | ||
// and try each one until one succeeds. This is rather undocumented, | ||
// but one signature verified should be enough to validate the RRSet, | ||
// even if other signatures fail to verify successfully. | ||
sortRRSIGsByAlgo(rrSigs) | ||
|
||
errs := new(joinedErrors) | ||
for _, rrSig := range rrSigs { | ||
if !rrSig.ValidityPeriod(time.Now()) { | ||
errs.add(fmt.Errorf("%w", ErrRRSigExpired)) | ||
continue | ||
} | ||
|
||
keyTag := rrSig.KeyTag | ||
dnsKey, ok := keyTagToDNSKey[keyTag] | ||
if !ok { | ||
errs.add(fmt.Errorf("%w: in %d DNSKEY(s) for key tag %d", | ||
ErrRRSigDNSKeyTag, len(keyTagToDNSKey), keyTag)) | ||
continue | ||
} | ||
|
||
err = rrSig.Verify(dnsKey, rrSet) | ||
if err != nil { | ||
errs.add(err) | ||
continue | ||
} | ||
|
||
return nil | ||
} | ||
|
||
return fmt.Errorf("%d RRSIGs failed to validate the RRSet: %w", | ||
len(rrSigs), errs) | ||
} | ||
|
||
var ( | ||
ErrRRSigDNSKeyTag = errors.New("DNSKEY not found") | ||
ErrRRSigExpired = errors.New("RRSIG has expired") | ||
) | ||
|
||
func verifyRRSetRRSig(rrSet []dns.RR, rrSig *dns.RRSIG, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) (err error) { | ||
if !rrSig.ValidityPeriod(time.Now()) { | ||
return fmt.Errorf("%w", ErrRRSigExpired) | ||
} | ||
|
||
keyTag := rrSig.KeyTag | ||
dnsKey, ok := keyTagToDNSKey[keyTag] | ||
if !ok { | ||
return fmt.Errorf("%w: in %d DNSKEY(s) for key tag %d", | ||
ErrRRSigDNSKeyTag, len(keyTagToDNSKey), keyTag) | ||
} | ||
|
||
err = rrSig.Verify(dnsKey, rrSet) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// sortRRSIGsByAlgo sorts RRSIGs by algorithm preference. | ||
func sortRRSIGsByAlgo(rrSigs []*dns.RRSIG) { | ||
sort.Slice(rrSigs, func(i, j int) bool { | ||
return lessDNSKeyAlgorithm(rrSigs[i].Algorithm, rrSigs[j].Algorithm) | ||
}) | ||
} | ||
|
||
var ( | ||
ErrRRSigSignerName = errors.New("signer name is not valid") | ||
) | ||
|
||
// The RRSIG RR's Signer's Name field MUST be the | ||
// name of the zone that contains the RRset. | ||
func rrSigCheckSignerName(rrSig *dns.RRSIG) (err error) { | ||
var validSignerNames []string | ||
switch rrSig.TypeCovered { | ||
case dns.TypeDS, dns.TypeCNAME, dns.TypeNSEC3: | ||
validSignerNames = []string{parentName(rrSig.Hdr.Name)} | ||
default: | ||
// For NSEC RRs, the signer name must be the apex name which | ||
// can be the owner or the parent of the owner of the RRSIG. | ||
// For example: | ||
// p.example.com. 3601 IN NSEC r.example.com. A RRSIG NSEC | ||
// p.example.com. 3601 IN RRSIG NSEC 13 3 3601 20240111000000 20231221000000 42950 example.com. 0se..m GY..w== | ||
// example.com. 3601 IN NSEC l.example.com. A NS SOA RRSIG NSEC DNSKEY | ||
// example.com. 3601 IN RRSIG NSEC 13 2 3601 20240111000000 20231221000000 42950 example.com. pe..B 4V..Q== | ||
|
||
// For other RRs, such as A, the signer name must be the owner | ||
// or the parent of the owner, for example for sigok.ippacket.stream. | ||
// the A record RRSIG owner is sigok.rsa2048-sha256.ippacket.stream. | ||
// and signer name is rsa2048-sha256.ippacket.stream. | ||
validSignerNames = []string{rrSig.Hdr.Name, parentName(rrSig.Hdr.Name)} | ||
} | ||
|
||
if isOneOf(rrSig.SignerName, validSignerNames...) { | ||
return nil | ||
} | ||
|
||
quoteStrings(validSignerNames) | ||
return fmt.Errorf("for %s: %w: %q should be %s", | ||
rrSigToOwnerTypeCovered(rrSig), ErrRRSigSignerName, | ||
rrSig.SignerName, orStrings(validSignerNames)) | ||
} | ||
|
||
func parentName(name string) (parent string) { | ||
const offset = 0 | ||
nextLabelStart, end := dns.NextLabel(name, offset) | ||
if end { | ||
// parent of 'tld.' is '.' and parent of '.' is '.' | ||
return "." | ||
} | ||
return name[nextLabelStart:] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
package dnssec | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func Test_sortRRSIGsByAlgo(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
rrSigs []*dns.RRSIG | ||
expected []*dns.RRSIG | ||
}{ | ||
"empty": {}, | ||
"single": { | ||
rrSigs: []*dns.RRSIG{ | ||
{Algorithm: dns.RSASHA1}, | ||
}, | ||
expected: []*dns.RRSIG{ | ||
{Algorithm: dns.RSASHA1}, | ||
}, | ||
}, | ||
"multiple": { | ||
rrSigs: []*dns.RRSIG{ | ||
{Algorithm: dns.ED25519}, | ||
{Algorithm: dns.RSASHA1}, | ||
{Algorithm: dns.ECCGOST}, | ||
{Algorithm: dns.RSASHA512}, | ||
{Algorithm: dns.ECDSAP384SHA384}, | ||
{Algorithm: dns.DSA}, | ||
}, | ||
expected: []*dns.RRSIG{ | ||
{Algorithm: dns.ED25519}, | ||
{Algorithm: dns.ECDSAP384SHA384}, | ||
{Algorithm: dns.RSASHA1}, | ||
{Algorithm: dns.RSASHA512}, | ||
{Algorithm: dns.ECCGOST}, | ||
{Algorithm: dns.DSA}, | ||
}, | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
sortRRSIGsByAlgo(testCase.rrSigs) | ||
|
||
assert.Equal(t, testCase.expected, testCase.rrSigs) | ||
}) | ||
} | ||
} | ||
|
||
func Test_rrSigCheckSignerName(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
rrSig *dns.RRSIG | ||
errWrapped error | ||
errMessage string | ||
}{ | ||
"a_signer_is_owner": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeA, | ||
SignerName: "example.com.", | ||
}, | ||
}, | ||
"a_signer_is_parent": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeA, | ||
SignerName: "com.", | ||
}, | ||
}, | ||
"a_signer_is_invalid": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeA, | ||
SignerName: ".", | ||
}, | ||
errWrapped: ErrRRSigSignerName, | ||
errMessage: `for RRSIG for owner example.com. and type A: ` + | ||
`signer name is not valid: "." should be "example.com." or "com."`, | ||
}, | ||
"ds_signer_is_parent": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeDS, | ||
SignerName: "com.", | ||
}, | ||
}, | ||
"ds_signer_is_owner": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeDS, | ||
SignerName: "example.com.", | ||
}, | ||
errWrapped: ErrRRSigSignerName, | ||
errMessage: `for RRSIG for owner example.com. and type DS: ` + | ||
`signer name is not valid: "example.com." should be "com."`, | ||
}, | ||
"cname_signer_is_parent": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeCNAME, | ||
SignerName: "com.", | ||
}, | ||
}, | ||
"cname_signer_is_owner": { | ||
rrSig: &dns.RRSIG{ | ||
Hdr: dns.RR_Header{ | ||
Name: "example.com.", | ||
}, | ||
TypeCovered: dns.TypeCNAME, | ||
SignerName: "example.com.", | ||
}, | ||
errWrapped: ErrRRSigSignerName, | ||
errMessage: `for RRSIG for owner example.com. and type CNAME: ` + | ||
`signer name is not valid: "example.com." should be "com."`, | ||
}, | ||
} | ||
|
||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
err := rrSigCheckSignerName(testCase.rrSig) | ||
|
||
assert.ErrorIs(t, err, testCase.errWrapped) | ||
if testCase.errWrapped != nil { | ||
assert.EqualError(t, err, testCase.errMessage) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package dnssec | ||
|
||
type signedData struct { | ||
zone string | ||
// TODO do we need this class field? Maybe for caching?? | ||
class uint16 | ||
dnsKeyResponse dnssecResponse | ||
dsResponse dnssecResponse | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
package dnssec | ||
|
||
import "fmt" | ||
|
||
func quoteStrings(elements []string) { | ||
for i := range elements { | ||
elements[i] = "\"" + elements[i] + "\"" | ||
} | ||
} | ||
|
||
func orStrings[T comparable](elements []T) (result string) { | ||
return joinStrings(elements, "or") | ||
} | ||
|
||
func joinStrings[T comparable](elements []T, lastJoin string) (result string) { | ||
if len(elements) == 0 { | ||
return "" | ||
} | ||
|
||
result = fmt.Sprint(elements[0]) | ||
for i := 1; i < len(elements); i++ { | ||
lastElement := i == len(elements)-1 | ||
if lastElement { | ||
result += " " + lastJoin + " " + fmt.Sprint(elements[i]) | ||
continue | ||
} | ||
result += ", " + fmt.Sprint(elements[i]) | ||
} | ||
|
||
return result | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
- ignore local names | ||
Check failure on line 1 in internal/dnssec/todo.md
|
||
<https://unbound.docs.nlnetlabs.nl/en/latest/topics/privacy/aggressive-nsec.html#> | ||
|
||
The NSEC3 RR SHOULD have the same TTL value as the SOA minimum TTL | ||
field. | ||
rename zone -> qname | ||
check rrsig labels field for wildcard needing NSEC for non empty response | ||
|
||
<https://datatracker.ietf.org/doc/html/rfc4035#section-5.3.1> | ||
|
||
nodata vs nxdomain bool flag somewhere |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package dnssec | ||
|
||
import ( | ||
"context" | ||
"net" | ||
"testing" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
func Test(t *testing.T) { | ||
ctx := context.Background() | ||
dialer := &net.Dialer{} | ||
netConn, err := dialer.DialContext(ctx, "udp", "1.1.1.1:53") | ||
if err != nil { | ||
t.Error(err) | ||
} | ||
dnsConn := &dns.Conn{Conn: netConn} | ||
|
||
client := &dns.Client{} | ||
request := new(dns.Msg) | ||
request.SetQuestion("berkeley.edu.", dns.TypeDNSKEY) | ||
response, _, err := client.ExchangeWithConn(request, dnsConn) | ||
if err != nil { | ||
t.Error(err) | ||
} | ||
t.Log(response.Truncated) | ||
|
||
err = dnsConn.Close() | ||
if err != nil { | ||
t.Error(err) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
// verify uses the zone data in the signed zone and its parent signed zones | ||
// to verify the DNSSEC chain of trust. | ||
// It starts the verification with the RRSet given as argument, and, | ||
// assuming a signature is valid, it walks through the slice of signed | ||
// zones checking the RRSIGs on the DNSKEY and DS resource record sets. | ||
func validateWithChain(desiredZone string, qType uint16, | ||
desiredResponse dnssecResponse, chain []signedData) (err error) { | ||
// Verify the root zone "." | ||
rootZone := chain[0] | ||
|
||
// Verify DNSKEY RRSet with its RRSIG and the DNSKEY matching | ||
// the RRSIG key tag. | ||
rootZoneKeyTagToDNSKey := makeKeyTagToDNSKey(rootZone.dnsKeyResponse.onlyAnswerRRSet()) | ||
err = verifyRRSetRRSigs(rootZone.dnsKeyResponse.onlyAnswerRRSet(), | ||
rootZone.dnsKeyResponse.onlyAnswerRRSigs(), rootZoneKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying DNSKEY records for the root zone: %w", | ||
err) | ||
} | ||
|
||
// Verify the root anchor digest against the digest of the DS | ||
// calculated from the DNSKEY of the root zone matching the | ||
// root anchor key tag. | ||
const ( | ||
rootAnchorKeyTag = 20326 | ||
rootAnchorDigest = "E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D" | ||
) | ||
rootAnchor := &dns.DS{ | ||
Algorithm: dns.RSASHA256, | ||
DigestType: dns.SHA256, | ||
KeyTag: rootAnchorKeyTag, | ||
Digest: rootAnchorDigest, | ||
} | ||
err = verifyDS(rootAnchor, rootZoneKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying the root anchor: %w", err) | ||
} | ||
|
||
parentZoneInsecure := false | ||
for i := 1; i < len(chain); i++ { | ||
// Iterate in this order: "com.", "example.com.", "abc.example.com." | ||
// Note the chain may not include the desired zone if one of its parent | ||
// zone is unsigned. Checking a parent zone is indeed unsigned | ||
// with DS-associated NSEC/NSEC3 RRSets also verifies the desired | ||
// zone is unsigned. | ||
zoneData := chain[i] | ||
parentZoneData := chain[i-1] | ||
|
||
switch { | ||
case zoneData.dsResponse.isNXDomain(): | ||
parentKeyTagToDNSKey := makeKeyTagToDNSKey(parentZoneData.dnsKeyResponse.onlyAnswerRRSet()) | ||
err = validateNxDomain(zoneData.zone, zoneData.dsResponse.authorityRRSets, parentKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("validating NXDOMAIN DS response: %w", err) | ||
} | ||
// no need to continue the verification for this zone since | ||
// child zones are unsigned. | ||
parentZoneInsecure = true | ||
case zoneData.dsResponse.isNoData(): | ||
parentKeyTagToDNSKey := makeKeyTagToDNSKey(parentZoneData.dnsKeyResponse.onlyAnswerRRSet()) | ||
err = validateNoDataDS(zoneData.zone, zoneData.dsResponse.authorityRRSets, parentKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("validating no data DS response: %w", err) | ||
} | ||
|
||
// no need to continue the verification for this zone since | ||
// child zones are unsigned. | ||
parentZoneInsecure = true | ||
default: // signed zone | ||
} | ||
|
||
if parentZoneInsecure { | ||
break | ||
} | ||
|
||
// Validate DNSKEY RRSet with its RRSIG and the DNSKEY matching | ||
// the RRSIG key tag. Note a zone should only have a DNSKEY RRSet | ||
// if it has a DS RRSet. | ||
keyTagToDNSKey := makeKeyTagToDNSKey(zoneData.dnsKeyResponse.onlyAnswerRRSet()) | ||
err = verifyRRSetRRSigs(zoneData.dnsKeyResponse.onlyAnswerRRSet(), | ||
zoneData.dnsKeyResponse.onlyAnswerRRSigs(), | ||
keyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("validating DNSKEY RRSet for zone %s: %w", | ||
zoneData.zone, err) | ||
} | ||
|
||
// Validate DS RRSet with its RRSIG and the DNSKEY of its parent zone | ||
// matching the RRSIG key tag. | ||
parentKeyTagToDNSKey := makeKeyTagToDNSKey(parentZoneData.dnsKeyResponse.onlyAnswerRRSet()) | ||
err = verifyRRSetRRSigs(zoneData.dsResponse.onlyAnswerRRSet(), | ||
zoneData.dsResponse.onlyAnswerRRSigs(), parentKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("validating DS RRSet for zone %s: %w", | ||
zoneData.zone, err) | ||
} | ||
|
||
// Validate DS RRSet digests with their corresponding DNSKEYs. | ||
err = verifyDSRRSet(zoneData.dsResponse.onlyAnswerRRSet(), keyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying DS RRSet for zone %s: %w", | ||
zoneData.zone, err) | ||
} | ||
} | ||
|
||
if !desiredResponse.isSigned() && !parentZoneInsecure { | ||
// The desired query returned an insecure response | ||
// (unsigned answers or no NSEC/NSEC3 RRSets) and | ||
// no parent zone was found to be unsigned, meaning this | ||
// is bogus. | ||
return fmt.Errorf("%w: desired query response is unsigned "+ | ||
"but no parent zone was found to be insecure", ErrBogus) | ||
} | ||
|
||
if parentZoneInsecure { | ||
// Whether the desired query is signed or not, if a parent zone | ||
// is insecure, the desired query is insecure. | ||
// For example IN A textsecure-service.whispersystems.org. has NSEC | ||
// signed by whispersystems.org., which has DNSKEYs but no DS record. | ||
return nil | ||
} | ||
|
||
// From this point, the desiredResponse is signed. | ||
|
||
// Note we validate the desired zone last since there might be a | ||
// break in the chain, where there is no DNSKEY for the parent zone | ||
// of the desired zone which has a DS RRSet. | ||
// For example for textsecure-service.whispersystems.org. | ||
var lastSecureZoneData signedData | ||
for i := len(chain) - 1; i >= 0; i-- { | ||
zoneData := chain[i] | ||
if len(zoneData.dsResponse.onlyAnswerRRSet()) > 0 { | ||
lastSecureZoneData = zoneData | ||
break | ||
} | ||
} | ||
|
||
lastSecureKeyTagToDNSKey := makeKeyTagToDNSKey(lastSecureZoneData.dnsKeyResponse.onlyAnswerRRSet()) | ||
switch { | ||
case desiredResponse.rcode == dns.RcodeNameError: // NXDOMAIN | ||
err = validateNxDomain(desiredZone, desiredResponse.authorityRRSets, | ||
lastSecureKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("validating negative NXDOMAIN response: %w", err) | ||
} | ||
case len(desiredResponse.answerRRSets) == 0: // NODATA | ||
err = validateNoData(desiredZone, qType, desiredResponse.authorityRRSets, | ||
lastSecureKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("validating negative NODATA response: %w", err) | ||
} | ||
default: | ||
// Verify the desired RRSets with the DNSKEY of the desired | ||
// zone matching the RRSIG key tag. | ||
err = verifyRRSetsRRSig(desiredResponse.answerRRSets, | ||
desiredResponse.authorityRRSets, lastSecureKeyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying RRSets with RRSigs: %w", err) | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// verifyDSRRSet verifies the digest of each received DS | ||
// is equal to the digest of the calculated DS obtained | ||
// from the DNSKEY (KSK) matching the received DS key tag. | ||
func verifyDSRRSet(dsRRSet []dns.RR, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) (err error) { | ||
for _, rr := range dsRRSet { | ||
ds := mustRRToDS(rr) | ||
err = verifyDS(ds, keyTagToDNSKey) | ||
if err != nil { | ||
return fmt.Errorf("verifying DS record: %w", err) | ||
} | ||
} | ||
return nil | ||
} | ||
|
||
var ( | ||
ErrDNSKeyNotFound = errors.New("DNSKEY resource record not found") | ||
ErrDNSKeyToDS = errors.New("failed to calculate DS from DNSKEY") | ||
ErrDNSKeyDSMismatch = errors.New("DS does not match DNS key") | ||
) | ||
|
||
func verifyDS(receivedDS *dns.DS, | ||
keyTagToDNSKey map[uint16]*dns.DNSKEY) error { | ||
// Note keyTagToDNSKey only contains ZSKs. | ||
dnsKey, ok := keyTagToDNSKey[receivedDS.KeyTag] | ||
if !ok { | ||
return fmt.Errorf("for RRSIG key tag %d: %w", | ||
receivedDS.KeyTag, ErrDNSKeyNotFound) | ||
} | ||
|
||
calculatedDS := dnsKey.ToDS(receivedDS.DigestType) | ||
if calculatedDS == nil { | ||
return fmt.Errorf("%w: for DNSKEY name %s and digest type %d", | ||
ErrDNSKeyToDS, dnsKey.Header().Name, receivedDS.DigestType) | ||
} | ||
|
||
if !strings.EqualFold(receivedDS.Digest, calculatedDS.Digest) { | ||
return fmt.Errorf("%w: DS record has digest %s "+ | ||
"but DNSKEY calculated DS has digest %s", | ||
ErrDNSKeyDSMismatch, receivedDS.Digest, calculatedDS.Digest) | ||
} | ||
|
||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package dnssec | ||
|
||
import ( | ||
"github.com/miekg/dns" | ||
"github.com/qdm12/dns/v2/internal/dnssec" | ||
) | ||
|
||
type handler struct { | ||
// Injected from middleware | ||
logger Logger | ||
next dns.Handler | ||
} | ||
|
||
func newHandler(logger Logger, next dns.Handler) *handler { | ||
return &handler{ | ||
logger: logger, | ||
next: next, | ||
} | ||
} | ||
|
||
func (h *handler) ServeDNS(w dns.ResponseWriter, request *dns.Msg) { | ||
response, err := dnssec.Validate(request, h.next) | ||
if err != nil { | ||
h.logger.Warn(err.Error()) | ||
response = new(dns.Msg) | ||
response.SetRcode(request, dns.RcodeServerFailure) | ||
} | ||
|
||
_ = w.WriteMsg(response) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package dnssec | ||
|
||
type Logger interface { | ||
Warn(message string) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
package dnssec | ||
|
||
import ( | ||
"fmt" | ||
"sync/atomic" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
// Middleware implements a DNSSEC validator. | ||
type Middleware struct { | ||
settings Settings | ||
wrapping atomic.Bool | ||
} | ||
|
||
func New(settings Settings) (middleware *Middleware, err error) { | ||
settings.SetDefaults() | ||
|
||
err = settings.Validate() | ||
if err != nil { | ||
return nil, fmt.Errorf("validating settings: %w", err) | ||
} | ||
|
||
return &Middleware{ | ||
settings: settings, | ||
}, nil | ||
} | ||
|
||
func (m *Middleware) String() string { | ||
return "DNSSEC validator" | ||
} | ||
|
||
// Wrap wraps the DNS handler with the middleware. | ||
func (m *Middleware) Wrap(next dns.Handler) dns.Handler { //nolint:ireturn | ||
previousWrapping := m.wrapping.Swap(true) | ||
if previousWrapping { | ||
panic("DNSSEC middleware cannot wrap more than once") | ||
} | ||
|
||
handler := newHandler(m.settings.Logger, next) | ||
return handler | ||
} | ||
|
||
// Stop is a no-op for the DNSSEC middleware. | ||
func (m *Middleware) Stop() (err error) { | ||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package dnssec | ||
|
||
import ( | ||
"github.com/qdm12/dns/v2/pkg/log/noop" | ||
"github.com/qdm12/gosettings" | ||
"github.com/qdm12/gotree" | ||
) | ||
|
||
type Settings struct { | ||
// Logger is the logger to use. | ||
// It defaults to a No-op implementation. | ||
Logger Logger | ||
} | ||
|
||
func (s *Settings) SetDefaults() { | ||
s.Logger = gosettings.DefaultComparable[Logger](s.Logger, noop.New()) | ||
} | ||
|
||
func (s *Settings) Validate() error { return nil } | ||
|
||
func (s *Settings) String() string { | ||
return s.ToLinesNode().String() | ||
} | ||
|
||
func (s *Settings) ToLinesNode() (node *gotree.Node) { | ||
node = gotree.New("DNSSEC settings:") | ||
return node | ||
} |