Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wip
Browse files Browse the repository at this point in the history
qdm12 committed Jan 3, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 09e170e commit 81296b4
Showing 38 changed files with 2,939 additions and 3 deletions.
1 change: 1 addition & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
FROM qmcgaw/godevcontainer
RUN apk add bind-tools
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -115,5 +115,4 @@ LABEL \
COPY --from=build --chown=1000 /tmp/gobuild/entrypoint /entrypoint

# Downloads and install some files
# TODO once DNSSEC is operational
# RUN /entrypoint build
1 change: 1 addition & 0 deletions docker-compose.yml
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
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -45,8 +45,6 @@ github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+Pymzi
github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/qdm12/goservices v0.1.0 h1:9sODefm/yuIGS7ynCkEnNlMTAYn9GzPhtcK4F69JWvc=
github.com/qdm12/goservices v0.1.0/go.mod h1:/JOFsAnHFiSjyoXxa5FlfX903h20K5u/3rLzCjYVMck=
github.com/qdm12/gosettings v0.4.0-rc4 h1:t4cZW/63EngMCzddQ+Je7aXKcPC7Poh/4IqxcbCBzgw=
github.com/qdm12/gosettings v0.4.0-rc4/go.mod h1:kZtFrO3sfWmMGG3scQB3GcawGe41LKo/4dR4h0dG104=
github.com/qdm12/gosettings v0.4.0-rc5 h1:DZ4PjfF/Xtx0QGMbinNPr7xRf0rtLfkIg4zZNXUoypY=
github.com/qdm12/gosettings v0.4.0-rc5/go.mod h1:kZtFrO3sfWmMGG3scQB3GcawGe41LKo/4dR4h0dG104=
github.com/qdm12/gosplash v0.1.0 h1:Sfl+zIjFZFP7b0iqf2l5UkmEY97XBnaKkH3FNY6Gf7g=
149 changes: 149 additions & 0 deletions internal/dnssec/chain.go
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
}
39 changes: 39 additions & 0 deletions internal/dnssec/chain_test.go
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)
})
}
}
25 changes: 25 additions & 0 deletions internal/dnssec/cname.go
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 ""
}
71 changes: 71 additions & 0 deletions internal/dnssec/dnskey.go
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
}
}
57 changes: 57 additions & 0 deletions internal/dnssec/dnskey_test.go
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)
}
})
}
59 changes: 59 additions & 0 deletions internal/dnssec/dnssec.go
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
}
15 changes: 15 additions & 0 deletions internal/dnssec/ds.go
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
}
28 changes: 28 additions & 0 deletions internal/dnssec/edns0.go
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()
}
26 changes: 26 additions & 0 deletions internal/dnssec/errors.go
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
}
54 changes: 54 additions & 0 deletions internal/dnssec/helpers.go
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
}
251 changes: 251 additions & 0 deletions internal/dnssec/integration_test.go
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)
}
90 changes: 90 additions & 0 deletions internal/dnssec/nodata.go
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
}
156 changes: 156 additions & 0 deletions internal/dnssec/nsec.go
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?
}
421 changes: 421 additions & 0 deletions internal/dnssec/nsec3.go

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions internal/dnssec/nsec3_test.go
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)
})
}
}
63 changes: 63 additions & 0 deletions internal/dnssec/nsec_test.go
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)
})
}
}
40 changes: 40 additions & 0 deletions internal/dnssec/nxdomain.go
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)
}
198 changes: 198 additions & 0 deletions internal/dnssec/query.go
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
}
150 changes: 150 additions & 0 deletions internal/dnssec/query_test.go
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,
},
}
}
39 changes: 39 additions & 0 deletions internal/dnssec/readme.md
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)
61 changes: 61 additions & 0 deletions internal/dnssec/response.go
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
}
115 changes: 115 additions & 0 deletions internal/dnssec/rrset.go
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
}
225 changes: 225 additions & 0 deletions internal/dnssec/rrsig.go
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:]
}
153 changes: 153 additions & 0 deletions internal/dnssec/rrsig_test.go
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)
}
})
}
}
9 changes: 9 additions & 0 deletions internal/dnssec/signeddata.go
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
}
31 changes: 31 additions & 0 deletions internal/dnssec/strings.go
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
}
11 changes: 11 additions & 0 deletions internal/dnssec/todo.md
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

GitHub Actions / markdown

First line in a file should be a top-level heading

internal/dnssec/todo.md:1 MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "- ignore local names"] https://github.com/DavidAnson/markdownlint/blob/v0.29.0/doc/md041.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
33 changes: 33 additions & 0 deletions internal/dnssec/truncated_test.go
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)
}
}
218 changes: 218 additions & 0 deletions internal/dnssec/validate.go
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
}
1 change: 1 addition & 0 deletions pkg/dot/handler.go
Original file line number Diff line number Diff line change
@@ -10,5 +10,6 @@ func newDNSHandler(ctx context.Context, settings ServerSettings) (
handler *server.Handler) {
dial := newDoTDial(settings.Resolver)
exchange := server.NewExchange("DoT", dial, settings.Logger)

return server.New(ctx, exchange, settings.Logger)
}
30 changes: 30 additions & 0 deletions pkg/middlewares/dnssec/handler.go
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)
}
5 changes: 5 additions & 0 deletions pkg/middlewares/dnssec/interfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package dnssec

type Logger interface {
Warn(message string)
}
47 changes: 47 additions & 0 deletions pkg/middlewares/dnssec/middleware.go
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
}
28 changes: 28 additions & 0 deletions pkg/middlewares/dnssec/settings.go
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
}

0 comments on commit 81296b4

Please sign in to comment.