Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dnssec #429

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be6ceb2
added basic DNSSEC validator
LeonardWalter Dec 16, 2024
7d5b973
implemented feedback for DNSSEC validation
LeonardWalter Dec 24, 2024
7e00772
added support for dnssec full chain verification
LeonardWalter Jan 3, 2025
000ec8c
Merge branch 'folbricht:master' into dnssec
LeonardWalter Jan 4, 2025
b7d1e15
update to DNSSEC, switch to sync/errgroup and fix bug in error handli…
LeonardWalter Jan 6, 2025
e58fbbd
Merge branch 'folbricht:master' into dnssec
LeonardWalter Jan 13, 2025
83b6499
update dnssec branch to slog and added improvements to code
LeonardWalter Jan 13, 2025
e6ed42f
Merge branch 'folbricht:master' into dnssec
LeonardWalter Jan 20, 2025
78cae45
Implemented feedback and added documentation for DNSSEC validator
LeonardWalter Jan 26, 2025
17793aa
fix potential nil, nil return value panic of getRRset
LeonardWalter Jan 26, 2025
096dd0a
Merge branch 'folbricht:master' into dnssec
LeonardWalter Feb 23, 2025
1194174
Added DNSSEC support for local trusted root-anchors
LeonardWalter Feb 24, 2025
ec3e12c
prototype support for NSEC & NSEC3 Proof of Non-Existence
LeonardWalter Feb 24, 2025
a4fa6b3
use dns.CompareDomainName instead of custom function
LeonardWalter Feb 24, 2025
9373624
fix critical NSEC logic flaw
LeonardWalter Feb 25, 2025
deb9ab5
use slice.Contains instead of loop
LeonardWalter Feb 25, 2025
a25121a
Merge branch 'folbricht:master' into dnssec
LeonardWalter Mar 24, 2025
4af8f01
Revert "use dns.CompareDomainName instead of custom function"
LeonardWalter Apr 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/routedns/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ func instantiateGroup(id string, g group, resolvers map[string]rdns.Resolver) er
if err != nil {
return err
}
case "dnssec":
if len(gr) != 1 {
return fmt.Errorf("type dnssec only supports one resolver in '%s'", id)
}
resolvers[id] = rdns.NewDNSSECenforcer(id, gr[0])
case "edns0-modifier":
if len(gr) != 1 {
return fmt.Errorf("type edns0-modifier only supports one resolver in '%s'", id)
Expand Down
313 changes: 313 additions & 0 deletions dnssec-backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
package rdns

import (
"errors"
"log/slog"
"strings"
"time"

"github.com/miekg/dns"
"golang.org/x/sync/errgroup"
)

var (
ErrResourceNotSigned = errors.New("resource is not signed with RRSIG")
ErrNoResult = errors.New("requested RR not found")
ErrDnskeyNotAvailable = errors.New("DNSKEY RR does not exist")
ErrDsNotAvailable = errors.New("DS RR does not exist")
ErrRRSigNotAvailable = errors.New("RRSIG does not exist")
ErrInvalidRRsig = errors.New("invalid RRSIG")
ErrForgedRRsig = errors.New("forged RRSIG header")
ErrRrsigValidationError = errors.New("RR doesn't validate against RRSIG")
ErrRrsigValidityPeriod = errors.New("invalid RRSIG validity period")
ErrUnknownDsDigestType = errors.New("unknown DS digest type")
ErrDsInvalid = errors.New("DS RR does not match DNSKEY")
ErrDelegationChain = errors.New("AuthChain has no Delegations")
)

type RRSet struct {
RrSet []dns.RR
RrSig *dns.RRSIG
}

type SignedZone struct {
Zone string
Dnskey *RRSet
Ds *RRSet
ParentZone *SignedZone
PubKeyLookup map[uint16]*dns.DNSKEY
}

type AuthenticationChain struct {
DelegationChain []SignedZone
}

func (r *RRSet) isSigned() bool {
return r.RrSig != nil
}

func (r *RRSet) isEmpty() bool {
return len(r.RrSet) < 1
}

func (r *RRSet) checkHeaderIntegrity(qname string) bool {
return r.RrSig != nil && r.RrSig.Header().Name != qname
}

func (z *SignedZone) checkHasDnskeys() bool {
return len(z.Dnskey.RrSet) > 0
}

func (z SignedZone) lookupPubKey(keyTag uint16) *dns.DNSKEY {
return z.PubKeyLookup[keyTag]
}

func (z SignedZone) addPubKey(k *dns.DNSKEY) {
z.PubKeyLookup[k.KeyTag()] = k
}

func removeRRSIGs(response *dns.Msg) *dns.Msg {
var filteredAnswers []dns.RR
filteredResponse := response.Copy()
for _, rr := range filteredResponse.Answer {
if _, isRRSIG := rr.(*dns.RRSIG); !isRRSIG {
filteredAnswers = append(filteredAnswers, rr)
}
}

filteredResponse.Answer = filteredAnswers
return filteredResponse
}

func setDNSSECdo(q *dns.Msg) *dns.Msg {
if q.IsEdns0() == nil {
q.SetEdns0(1024, true)
}
q.IsEdns0().SetDo()
return q
}

func newQuery(qname string, qtype uint16) *dns.Msg {
dnsMessage := &dns.Msg{
MsgHdr: dns.MsgHdr{
RecursionDesired: true,
},
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically you don't need this as SetQuestion() below does it already (see https://github.com/miekg/dns/blob/v1.1.63/defaults.go#L35).

}
dnsMessage.SetEdns0(4096, true)
dnsMessage.SetQuestion(qname, qtype)
return dnsMessage
}

func getRRset(qname string, qtype uint16, resolver Resolver, ci ClientInfo) (*RRSet, error) {
q := newQuery(qname, qtype)
r, err := doQuery(q, resolver, ci)
if err != nil || r == nil {
return nil, err
}
return extractRRset(r)
}

func extractRRset(r *dns.Msg) (*RRSet, error) {
result := &RRSet{RrSet: make([]dns.RR, 0)}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
result := &RRSet{RrSet: make([]dns.RR, 0)}
result := &RRSet{}

No need to initialize an empty list, you can read from and append to a nil list.

if r.Answer == nil {
return result, nil
}

result.RrSet = make([]dns.RR, 0, len(r.Answer))
for _, rr := range r.Answer {
switch t := rr.(type) {
case *dns.RRSIG:
result.RrSig = t
case *dns.DNSKEY, *dns.DS, *dns.A, *dns.AAAA:
result.RrSet = append(result.RrSet, rr)
}
}
return result, nil
}

func doQuery(q *dns.Msg, resolver Resolver, ci ClientInfo) (*dns.Msg, error) {
r, err := resolver.Resolve(q, ci)
if err != nil {
return nil, err
}
if r.Rcode == dns.RcodeNameError {
Log.Info("no such domain", "info", qName(r))
return nil, ErrNoResult
}
if r == nil || r.Rcode == dns.RcodeSuccess {
return r, err
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return r, err
return r, nil

err has already been checked and must be nil here.

}
return nil, ErrInvalidRRsig
}

func queryDelegation(domainName string, resolver Resolver, ci ClientInfo) (*SignedZone, error) {
signedZone := &SignedZone{
Zone: domainName,
Ds: &RRSet{},
Dnskey: &RRSet{},
}
signedZone.PubKeyLookup = make(map[uint16]*dns.DNSKEY)

var g errgroup.Group
g.Go(func() error {
var err error
signedZone.Dnskey, err = getRRset(domainName, dns.TypeDNSKEY, resolver, ci)
if err != nil {
return err
}

for _, rr := range signedZone.Dnskey.RrSet {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRRSet() can return nil, nil which would trigger a panic here

defer func() {
if r := recover(); r != nil {
Log.Warn("panic occurred",
slog.String("domainName", domainName),
slog.Any("panic", r),
)
}
}()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this? Seems more like we don't trust our own code to handle all nil pointers correctly.

signedZone.addPubKey(rr.(*dns.DNSKEY))
}
return nil
})

g.Go(func() error {
var err error
signedZone.Ds, err = getRRset(domainName, dns.TypeDS, resolver, ci)
if err != nil {
Log.Error("DS query failed",
slog.String("domainName", domainName),
"error", err,
)

return err
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than logging from within, might be better to extend the error and pass it back to the caller to handle. Something like

return fmt.Errorf("DS query failed for %q: %w", domainName, err)

}
return nil
})

if err := g.Wait(); err != nil {
return nil, err
}
return signedZone, nil
}

func (authChain *AuthenticationChain) Populate(domainName string, resolver Resolver, ci ClientInfo) error {
qnameComponents := strings.Split(domainName, ".")
zonesToVerify := len(qnameComponents)
authChain.DelegationChain = make([]SignedZone, zonesToVerify)

var g errgroup.Group
for i := 0; i < zonesToVerify; i++ {
zoneName := dns.Fqdn(strings.Join(qnameComponents[i:], "."))
index := i

g.Go(func() error {
delegation, err := queryDelegation(zoneName, resolver, ci)
if err != nil {
return err
}
authChain.DelegationChain[index] = *delegation
if index > 0 {
authChain.DelegationChain[index-1].ParentZone = delegation
}
return nil
})
}

if err := g.Wait(); err != nil {
return err
}
return nil
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if err := g.Wait(); err != nil {
return err
}
return nil
return g.Wait()

}

func (authChain *AuthenticationChain) Verify(answerRRset *RRSet) error {
zones := authChain.DelegationChain
if len(zones) == 0 {
return ErrDelegationChain
}

signedZone := authChain.DelegationChain[0]
if !signedZone.checkHasDnskeys() {
return ErrDnskeyNotAvailable
}

err := signedZone.verifyRRSIG(answerRRset)
if err != nil {
return ErrInvalidRRsig
}

for _, signedZone := range authChain.DelegationChain {
defer func() {
if err := recover(); err != nil {
Log.Error("AuthChain panic occurred", "error", err)
}
}()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, there should be a better way to deal with panics, i.e. avoid them at all.


if signedZone.Dnskey.isEmpty() {
return ErrDnskeyNotAvailable
}

err := signedZone.verifyRRSIG(signedZone.Dnskey)
if err != nil {
return ErrRrsigValidationError
}

if signedZone.ParentZone != nil {

if signedZone.Ds.isEmpty() {
return ErrDsNotAvailable
}

err := signedZone.ParentZone.verifyRRSIG(signedZone.Ds)
if err != nil {
return ErrRrsigValidationError
}
err = signedZone.verifyDS(signedZone.Ds.RrSet)
if err != nil {
return ErrDsInvalid
}
}
}
return nil
}

func (z SignedZone) verifyRRSIG(signedRRset *RRSet) (err error) {
if !signedRRset.isSigned() {
return ErrRRSigNotAvailable
}

key := z.lookupPubKey(signedRRset.RrSig.KeyTag)
if key == nil {
return ErrDnskeyNotAvailable
}

err = signedRRset.RrSig.Verify(key, signedRRset.RrSet)
if err != nil {
return err
}

if !signedRRset.RrSig.ValidityPeriod(time.Now()) {
return ErrRrsigValidityPeriod
}
return nil
}

func (z SignedZone) verifyDS(dsRrset []dns.RR) (err error) {
for _, rr := range dsRrset {
ds := rr.(*dns.DS)
if ds.DigestType != dns.SHA256 {
continue
}

parentDsDigest := strings.ToUpper(ds.Digest)
key := z.lookupPubKey(ds.KeyTag)
if key == nil {
return ErrDnskeyNotAvailable
}
dsDigest := strings.ToUpper(key.ToDS(ds.DigestType).Digest)
if parentDsDigest == dsDigest {
return nil
}
return ErrDsInvalid
}
return ErrUnknownDsDigestType
}
70 changes: 70 additions & 0 deletions dnssec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package rdns

import (
"log/slog"

"github.com/miekg/dns"
"golang.org/x/sync/errgroup"
)

// The DNSSEC enforcer will upgrade DNS queries to also request DNSSEC and only forwards DNSSEC validated responses
type DNSSECenforcer struct {
id string
resolver Resolver
}

var _ Resolver = &DNSSECenforcer{}

func NewDNSSECenforcer(id string, resolver Resolver) *DNSSECenforcer {
return &DNSSECenforcer{id: id, resolver: resolver}
}

func (d *DNSSECenforcer) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) {
var g errgroup.Group

var rrSet *RRSet
var response *dns.Msg

// Resolve the A query with RRSIG option
g.Go(func() error {
res, err := d.resolver.Resolve(setDNSSECdo(q), ci)
if err != nil {
return err
}
response = res.Copy()
rrSet, err = extractRRset(res)
if err != nil {
return err
}
if rrSet.isEmpty() {
return ErrNoResult
}
if !rrSet.isSigned() {
return ErrResourceNotSigned
}
if rrSet.checkHeaderIntegrity(qName(q)) {
return ErrForgedRRsig
}
return nil
})

// Resolve the entire DNSSEC authentication chain
authChain := &AuthenticationChain{}
g.Go(func() error {
return authChain.Populate(qName(q), d.resolver, ci)
})

if err := g.Wait(); err != nil {
return nil, err
}
if err := authChain.Verify(rrSet); err != nil {
return nil, err
}

Log.Debug("Valid DNS Record Answer", slog.String("domain", qName(q)))
return removeRRSIGs(response), nil
}

func (d *DNSSECenforcer) String() string {
return d.id
}
Loading