-
-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
15 changed files
with
678 additions
and
0 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 |
---|---|---|
@@ -0,0 +1,235 @@ | ||
package dnssec | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/qdm12/dns/v2/internal/server" | ||
) | ||
|
||
// delegationChain is the DNSSEC chain of trust from the | ||
// queried zone to the root (.) zone. | ||
// See https://www.ietf.org/rfc/rfc4033.txt | ||
type delegationChain []*signedZone | ||
|
||
// newDelegationChain queries the RRs required for the zone validation. | ||
// It begins the queries at the desired zone and then go | ||
// up the delegation tree until it reaches the root zone. | ||
// It returns a new delegation chain of signed zones where the | ||
// first signed zone (index 0) is the child zone and the last signed | ||
// zone is the root zone. | ||
func newDelegationChain(ctx context.Context, exchange server.Exchange, | ||
zone string, qClass uint16) (chain delegationChain, err error) { | ||
zoneParts := strings.Split(zone, ".") | ||
chain = make(delegationChain, len(zoneParts)) | ||
|
||
type result struct { | ||
i int | ||
signedZone *signedZone | ||
err error | ||
} | ||
results := make(chan result) | ||
|
||
for i := range zoneParts { | ||
// 'example.com.', 'com.', '.' | ||
go func(i int, results chan<- result) { | ||
result := result{i: i} | ||
zoneName := dns.Fqdn(strings.Join(zoneParts[i:], ".")) | ||
result.signedZone, result.err = queryDelegation(ctx, exchange, zoneName, qClass) | ||
if result.err != nil { | ||
result.err = fmt.Errorf("querying delegation for %s: %w", zoneName, result.err) | ||
} | ||
results <- result | ||
}(i, results) | ||
} | ||
|
||
for range chain { | ||
result := <-results | ||
if result.err != nil && err == nil { | ||
err = result.err | ||
continue | ||
} | ||
chain[result.i] = result.signedZone | ||
} | ||
close(results) | ||
|
||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return chain, nil | ||
} | ||
|
||
// queryDelegation obtains the DNSKEY records and the DS | ||
// records for a given zone. It does not query the | ||
// (non existent) DS record for the root zone. | ||
func queryDelegation(ctx context.Context, exchange server.Exchange, | ||
zone string, qClass uint16) (sz *signedZone, err error) { | ||
if zone == "." { | ||
// Only query DNSKEY since root zone has no DS record. | ||
rrsig, rrset, err := queryDNSKey(ctx, exchange, zone, qClass) | ||
if err != nil { | ||
return nil, fmt.Errorf("querying DNSKEY records: %w", err) | ||
} | ||
return &signedZone{ | ||
zone: zone, | ||
dnsKeyRRSig: rrsig, | ||
dnsKeyRRSet: rrset, | ||
keyTagToDNSKey: dnsKeyRRSetToMap(rrset), | ||
}, nil | ||
} | ||
|
||
ctx, cancel := context.WithCancel(ctx) | ||
defer cancel() | ||
|
||
type result struct { | ||
t uint16 | ||
rrsig *dns.RRSIG | ||
rrset []dns.RR | ||
err error | ||
} | ||
results := make(chan result) | ||
|
||
go func(ctx context.Context, exchange server.Exchange, zone string, results chan<- result) { | ||
result := result{t: dns.TypeDNSKEY} | ||
result.rrsig, result.rrset, result.err = queryDNSKey(ctx, exchange, zone, qClass) | ||
if result.err != nil { | ||
result.err = fmt.Errorf("querying DNSKEY records: %w", result.err) | ||
} | ||
results <- result | ||
}(ctx, exchange, zone, results) | ||
|
||
go func(ctx context.Context, exchange server.Exchange, zone string, results chan<- result) { | ||
result := result{t: dns.TypeDS} | ||
result.rrsig, result.rrset, result.err = queryDS(ctx, exchange, zone, qClass) | ||
if result.err != nil { | ||
result.err = fmt.Errorf("querying DS records: %w", result.err) | ||
} | ||
results <- result | ||
}(ctx, exchange, zone, results) | ||
|
||
sz = &signedZone{ | ||
zone: zone, | ||
} | ||
for i := 0; i < 2; i++ { | ||
result := <-results | ||
if result.err != nil { | ||
if err == nil { // first error encountered | ||
err = result.err | ||
cancel() | ||
} | ||
continue | ||
} | ||
if result.t == dns.TypeDS { | ||
sz.dsRRSig, sz.dsRRSet = result.rrsig, result.rrset | ||
} else { | ||
sz.dnsKeyRRSig, sz.dnsKeyRRSet = result.rrsig, result.rrset | ||
sz.keyTagToDNSKey = dnsKeyRRSetToMap(result.rrset) | ||
} | ||
} | ||
close(results) | ||
|
||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return sz, nil | ||
} | ||
|
||
var ( | ||
ErrRecordNotFound = errors.New("record not found") | ||
ErrRRSigNotFound = errors.New("RRSIG not found") | ||
) | ||
|
||
func queryDNSKey(ctx context.Context, exchange server.Exchange, | ||
zone string, qClass uint16) (rrsig *dns.RRSIG, | ||
rrset []dns.RR, err error) { | ||
rrsig, rrset, err = fetchRRSetWithRRSig(ctx, exchange, zone, qClass, dns.TypeDNSKEY) | ||
switch { | ||
case err != nil: | ||
return nil, nil, err | ||
case len(rrset) == 0: | ||
return nil, nil, fmt.Errorf("%w", ErrRecordNotFound) | ||
case rrsig == nil: | ||
return nil, nil, fmt.Errorf("%w", ErrRRSigNotFound) | ||
} | ||
return rrsig, rrset, nil | ||
} | ||
|
||
func queryDS(ctx context.Context, exchange server.Exchange, | ||
zone string, qClass uint16) (rrsig *dns.RRSIG, | ||
rrset []dns.RR, err error) { | ||
rrsig, rrset, err = fetchRRSetWithRRSig(ctx, exchange, zone, qClass, dns.TypeDS) | ||
switch { | ||
case err != nil: | ||
return nil, nil, err | ||
case len(rrset) == 0: | ||
return nil, nil, fmt.Errorf("%w", ErrRecordNotFound) | ||
case rrsig == nil: | ||
return nil, nil, fmt.Errorf("%w", ErrRRSigNotFound) | ||
} | ||
return rrsig, rrset, nil | ||
} | ||
|
||
var ( | ||
ErrRRSetValidation = errors.New("RRSet validation failed") | ||
) | ||
|
||
// verify uses the zone data in the signed zone and its parent signed zones | ||
// to validate the DNSSEC chain of trust. | ||
// It starts the verification in the RRSet supplied as parameter (verifies | ||
// the RRSIG on the answer RRs), and, assuming a signature is correct and | ||
// valid, it walks through the linked list of signed zones checking the RRSIGs on | ||
// the DNSKEY and DS resource record sets, as well as correctness of each | ||
// delegation using the lower level methods in signedZone. | ||
func (dc delegationChain) verify(rrsig *dns.RRSIG, rrset []dns.RR) error { | ||
if rrsig == nil { | ||
return ErrRRSigNotFound | ||
} | ||
|
||
signedZone := dc[0] // child desired zone | ||
|
||
// Verify desired RRSet | ||
err := signedZone.verifyRRSIG(rrsig, rrset) | ||
if err != nil { | ||
return fmt.Errorf("for zone %s and RRSIG key tag %d: %w", | ||
signedZone.zone, rrsig.KeyTag, err) | ||
} | ||
|
||
for i, signedZone := range dc { | ||
// Verify DNSKEY signature | ||
err := signedZone.verifyRRSIG(signedZone.dnsKeyRRSig, signedZone.dnsKeyRRSet) | ||
if err != nil { | ||
return fmt.Errorf("for zone %s and RRSIG key tag %d: %w", | ||
signedZone.zone, signedZone.dsRRSig.KeyTag, err) | ||
} | ||
|
||
if signedZone.zone == "." { // last element in chain | ||
err = verifyRootSignedZone(signedZone) | ||
if err != nil { | ||
return fmt.Errorf("failed validating root zone: %w", err) | ||
} | ||
|
||
break | ||
} | ||
|
||
// Verify DS signature with parent zone DNSKEY | ||
parentSignedZone := dc[i+1] | ||
err = parentSignedZone.verifyRRSIG(signedZone.dsRRSig, signedZone.dsRRSet) | ||
if err != nil { | ||
return fmt.Errorf("for zone %s and RRSIG key tag %d: %w", | ||
signedZone.zone, signedZone.dsRRSig.KeyTag, ErrRRSetValidation) | ||
} | ||
|
||
// Verify DS hash | ||
err = signedZone.verifyDSRRSet() | ||
if err != nil { | ||
return err | ||
} | ||
} | ||
|
||
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,30 @@ | ||
package dnssec | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/miekg/dns" | ||
) | ||
|
||
var ( | ||
ErrInvalidDS = errors.New("DS RR record does not match DNS key") | ||
ErrUnknownDsDigestType = errors.New("unknown DS digest type") | ||
) | ||
|
||
func verifyDS(receivedDS *dns.DS, dnsKey *dns.DNSKEY) error { | ||
calculatedDS := dnsKey.ToDS(receivedDS.DigestType) | ||
if calculatedDS == nil { | ||
return fmt.Errorf("%w: %s", ErrUnknownDsDigestType, | ||
dns.HashToString[receivedDS.DigestType]) | ||
} | ||
|
||
if !strings.EqualFold(receivedDS.Digest, calculatedDS.Digest) { | ||
return fmt.Errorf("%w: DS record has digest %s "+ | ||
"but calculated digest is %s", ErrInvalidDS, | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
//go:build integration | ||
// +build integration | ||
|
||
package dnssec | ||
|
||
import ( | ||
"context" | ||
"net" | ||
"testing" | ||
"time" | ||
|
||
"github.com/miekg/dns" | ||
"github.com/qdm12/dns/v2/internal/server" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func getRRSetWithoutValidation(t *testing.T, zone string, | ||
qType, qClass uint16) (rrset []dns.RR) { | ||
t.Helper() | ||
|
||
request := new(dns.Msg) | ||
request.SetQuestion(zone, qType) | ||
request.Question[0].Qclass = qClass | ||
|
||
response, _, err := new(dns.Client).Exchange(request, "1.1.1.1:53") | ||
require.NoError(t, err) | ||
|
||
// Clear TTL since they are not predicatable | ||
for _, rr := range response.Answer { | ||
rr.Header().Ttl = 0 | ||
} | ||
|
||
return response.Answer | ||
} | ||
|
||
func testExchange() server.Exchange { | ||
client := &dns.Client{} | ||
dialer := &net.Dialer{} | ||
return func(ctx context.Context, request *dns.Msg) (response *dns.Msg, err error) { | ||
netConn, err := dialer.DialContext(ctx, "udp", "1.1.1.1:53") | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
dnsConn := &dns.Conn{Conn: netConn} | ||
response, _, err = client.ExchangeWithConn(request, dnsConn) | ||
|
||
_ = dnsConn.Close() | ||
|
||
return response, err | ||
} | ||
} | ||
|
||
func Test_fetchAndValidateZone(t *testing.T) { | ||
t.Parallel() | ||
|
||
testCases := map[string]struct { | ||
zone string | ||
dnsType uint16 | ||
exchange server.Exchange | ||
rrset []dns.RR | ||
errWrapped error | ||
errMessage string | ||
}{ | ||
"valid DNSSEC": { | ||
zone: "qqq.ninja.", | ||
dnsType: dns.TypeA, | ||
rrset: getRRSetWithoutValidation(t, "qqq.ninja.", dns.TypeA, dns.ClassINET), | ||
exchange: testExchange(), | ||
}, | ||
"www.iana.org.": { | ||
zone: "vip.icann.org.", | ||
dnsType: dns.TypeA, | ||
exchange: testExchange(), | ||
}, | ||
"no DNSSEC": { | ||
zone: "github.com.", | ||
dnsType: dns.TypeA, | ||
rrset: getRRSetWithoutValidation(t, "github.com.", dns.TypeA, dns.ClassINET), | ||
exchange: testExchange(), | ||
}, | ||
"bad DNSSEC already failed by upstream": { | ||
zone: "dnssec-failed.org.", | ||
dnsType: dns.TypeA, | ||
exchange: testExchange(), | ||
errWrapped: ErrValidationFailedUpstream, | ||
errMessage: "cannot fetch desired RRSet and RRSig: " + | ||
"for dnssec-failed.org. IN A: " + | ||
"DNSSEC validation might had failed upstream", | ||
}, | ||
} | ||
for name, testCase := range testCases { | ||
testCase := testCase | ||
t.Run(name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
deadline, ok := t.Deadline() | ||
if !ok { | ||
deadline = time.Now().Add(5 * time.Second) | ||
} | ||
|
||
ctx, cancel := context.WithDeadline(context.Background(), deadline) | ||
defer cancel() | ||
|
||
rrset, err := fetchAndValidateZone(ctx, testCase.exchange, | ||
testCase.zone, dns.ClassINET, testCase.dnsType) | ||
|
||
// Remove TTL fields from rrset | ||
for i := range rrset { | ||
rrset[i].Header().Ttl = 0 | ||
} | ||
|
||
assert.Equal(t, testCase.rrset, rrset) | ||
require.ErrorIs(t, err, testCase.errWrapped) | ||
if testCase.errWrapped != nil { | ||
assert.EqualError(t, err, testCase.errMessage) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func Benchmark_fetchAndValidateZone(b *testing.B) { | ||
ctx := context.Background() | ||
const zone = "qqq.ninja." | ||
const dnsType = dns.TypeA | ||
exchange := testExchange() | ||
|
||
for i := 0; i < b.N; i++ { | ||
_, _ = fetchAndValidateZone(ctx, exchange, zone, dns.ClassINET, dnsType) | ||
} | ||
} |
Oops, something went wrong.