diff --git a/README.md b/README.md index 5f65c6a..1278926 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,15 @@ When querying DNSSEC enabled zones, it performs a full verification of the resou * Performs the cryptographic verification of the `RRSIG` of the `DNSKEY` RRset with the public KSK * Checks the validity period of the `RRSIG` records -Following these cryptographic verifications, the package then validates the authentication chain by walking up the delegation chain, checking the public `DNSKEY` RRs against the `DS` records in each parent zone, up to the TLD zone. (For a more in-depth description of how DNSSEC works, see [this guide](https://www.cloudflare.com/dns/dnssec/how-dnssec-works/).) +Following these cryptographic verifications, the package then validates the authentication chain by walking up the delegation chain, checking the public `DNSKEY` RRs against the `DS` records in each parent zone, up to the root zone. + +### Root Zone Trust Anchor + +The library includes the official IANA root zone trust anchor (KSK-2017, key tag 20326) to validate the root zone DNSKEY. This ensures that the entire chain of trust is verified all the way up to the root, preventing a misbehaving server from spoofing the DNS hierarchy. + +The root zone validation follows [RFC 4033](https://tools.ietf.org/html/rfc4033) and [RFC 4034](https://tools.ietf.org/html/rfc4034) specifications for DNSSEC validation. + +(For a more in-depth description of how DNSSEC works, see [this guide](https://www.cloudflare.com/dns/dnssec/how-dnssec-works/).) In case of any validation errors, the method returns a non-nil `err` value, and an empty result set. diff --git a/authchain.go b/authchain.go index cf26c35..ae9dd03 100644 --- a/authchain.go +++ b/authchain.go @@ -51,7 +51,8 @@ func (authChain *AuthenticationChain) Populate(domainName string) error { // valid, it walks through the delegationChain 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 (authChain *AuthenticationChain) Verify(answerRRset *RRSet) error { +// The trustAnchor parameter is used to validate the root zone DNSKEY. +func (authChain *AuthenticationChain) Verify(answerRRset *RRSet, trustAnchor *TrustAnchor) error { signedZone := authChain.delegationChain[0] if !signedZone.checkHasDnskeys() { @@ -64,7 +65,8 @@ func (authChain *AuthenticationChain) Verify(answerRRset *RRSet) error { return ErrInvalidRRsig } - for _, signedZone := range authChain.delegationChain { + for i := range authChain.delegationChain { + signedZone := authChain.delegationChain[i] if signedZone.dnskey.IsEmpty() { log.Printf("DNSKEY RR does not exist on %s\n", signedZone.zone) @@ -95,6 +97,15 @@ func (authChain *AuthenticationChain) Verify(answerRRset *RRSet) error { log.Printf("DS does not validate: %s", err) return ErrDsInvalid } + } else { + // This is the root zone (no parent), validate against trust anchor + if signedZone.zone == "." { + err := trustAnchor.VerifyRootZone(signedZone) + if err != nil { + log.Printf("Root zone does not match trust anchor: %s\n", err) + return ErrRootZoneNotTrusted + } + } } } return nil diff --git a/goresolver.go b/goresolver.go index baa8041..f4c8419 100644 --- a/goresolver.go +++ b/goresolver.go @@ -19,6 +19,7 @@ type Resolver struct { timeNow func() time.Time dnsClient *dns.Client dnsClientConfig *dns.ClientConfig + trustAnchor *TrustAnchor } // Errors returned by the verification/validation methods at all levels. @@ -109,5 +110,12 @@ func NewResolver(resolvConf string) (res *Resolver, err error) { } resolver.queryFn = localQuery resolver.timeNow = time.Now + + // Initialize the root zone trust anchor + resolver.trustAnchor, err = NewTrustAnchor() + if err != nil { + return nil, err + } + return resolver, nil } diff --git a/lookup.go b/lookup.go index ae6cdad..cfef372 100644 --- a/lookup.go +++ b/lookup.go @@ -52,7 +52,7 @@ func (resolver *Resolver) LookupIP(qname string) (ips []net.IP, err error) { } resultIPs := make([]net.IP, MaxReturnedIPAddressesCount) for _, answer := range answers { - err = authChain.Verify(answer) + err = authChain.Verify(answer, resolver.trustAnchor) if err != nil { log.Printf("DNSSEC validation failed: %s\n", err) continue @@ -100,7 +100,7 @@ func (resolver *Resolver) LookupIPType(qname string, qtype uint16) (ips []net.IP return nil, err } - err = authChain.Verify(answer) + err = authChain.Verify(answer, resolver.trustAnchor) if err != nil { log.Printf("DNSSEC validation failed: %s\n", err) return nil, err @@ -142,7 +142,7 @@ func (resolver *Resolver) StrictNSQuery(qname string, qtype uint16) (rrSet []dns return nil, err } - err = authChain.Verify(answer) + err = authChain.Verify(answer, resolver.trustAnchor) if err != nil { log.Printf("DNSSEC validation failed: %s\n", err) return nil, err diff --git a/trustanchor.go b/trustanchor.go new file mode 100644 index 0000000..5f7185f --- /dev/null +++ b/trustanchor.go @@ -0,0 +1,83 @@ +package goresolver + +import ( + "errors" + + "github.com/miekg/dns" +) + +// ErrRootZoneNotTrusted is returned when the root zone DNSKEY does not +// match any configured trust anchor +var ErrRootZoneNotTrusted = errors.New("root zone DNSKEY does not match trust anchor") + +// TrustAnchor represents a DNSSEC trust anchor for the root zone +type TrustAnchor struct { + dnskeys []*dns.DNSKEY +} + +// defaultRootTrustAnchors contains the official IANA root zone trust anchors +// These are the KSK (Key Signing Key) records for the root zone +// +// KSK-2017 (Key Tag 20326) - Currently active +// Reference: https://www.iana.org/dnssec/files +var defaultRootTrustAnchors = []string{ + // KSK-2017 (Key Tag 20326) + ". IN DNSKEY 257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=", +} + +// NewTrustAnchor creates a new TrustAnchor with the default root zone +// trust anchors +func NewTrustAnchor() (*TrustAnchor, error) { + ta := &TrustAnchor{ + dnskeys: make([]*dns.DNSKEY, 0, len(defaultRootTrustAnchors)), + } + + for _, anchor := range defaultRootTrustAnchors { + rr, err := dns.NewRR(anchor) + if err != nil { + return nil, err + } + dnskey, ok := rr.(*dns.DNSKEY) + if !ok { + return nil, errors.New("trust anchor is not a DNSKEY record") + } + ta.dnskeys = append(ta.dnskeys, dnskey) + } + + return ta, nil +} + +// VerifyRootZone validates that the root zone DNSKEY matches one of the +// configured trust anchors. It returns nil if validation succeeds. +func (ta *TrustAnchor) VerifyRootZone(rootZone SignedZone) error { + if rootZone.zone != "." { + return errors.New("not a root zone") + } + + // Check that at least one of the KSKs in the root zone matches + // a trust anchor + for _, trustAnchor := range ta.dnskeys { + trustAnchorKeyTag := trustAnchor.KeyTag() + + // Look up the key in the root zone by key tag + rootKey := rootZone.lookupPubKey(trustAnchorKeyTag) + if rootKey == nil { + continue + } + + // Compare the DNSKEY records + if keysMatch(trustAnchor, rootKey) { + return nil + } + } + + return ErrRootZoneNotTrusted +} + +// keysMatch compares two DNSKEY records for equality +func keysMatch(a, b *dns.DNSKEY) bool { + return a.Flags == b.Flags && + a.Protocol == b.Protocol && + a.Algorithm == b.Algorithm && + a.PublicKey == b.PublicKey +} diff --git a/trustanchor_test.go b/trustanchor_test.go new file mode 100644 index 0000000..40df9ee --- /dev/null +++ b/trustanchor_test.go @@ -0,0 +1,149 @@ +package goresolver + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestNewTrustAnchor(t *testing.T) { + ta, err := NewTrustAnchor() + if err != nil { + t.Fatalf("NewTrustAnchor failed: %v", err) + } + if ta == nil { + t.Fatal("NewTrustAnchor returned nil") + } + if len(ta.dnskeys) == 0 { + t.Fatal("NewTrustAnchor should have at least one trust anchor") + } + + // Verify that KSK-2017 (key tag 20326) is present + foundKSK := false + for _, key := range ta.dnskeys { + if key.KeyTag() == 20326 { + foundKSK = true + // Verify it's a KSK (Key Signing Key) + if key.Flags != 257 { + t.Errorf("Expected KSK flag 257, got %d", key.Flags) + } + // Verify algorithm is RSA/SHA-256 (8) + if key.Algorithm != 8 { + t.Errorf("Expected algorithm 8, got %d", key.Algorithm) + } + } + } + if !foundKSK { + t.Error("KSK-2017 (key tag 20326) not found in trust anchors") + } +} + +func TestVerifyRootZone_Valid(t *testing.T) { + ta, err := NewTrustAnchor() + if err != nil { + t.Fatalf("NewTrustAnchor failed: %v", err) + } + + // Create a mock root zone with the correct KSK + rootZone := &SignedZone{ + zone: ".", + dnskey: &RRSet{rrSet: []dns.RR{ta.dnskeys[0]}}, + ds: &RRSet{}, + pubKeyLookup: make(map[uint16]*dns.DNSKEY), + } + + // Add the trust anchor key to the root zone + for _, key := range ta.dnskeys { + rootZone.addPubKey(key) + } + + err = ta.VerifyRootZone(*rootZone) + if err != nil { + t.Errorf("VerifyRootZone should succeed with matching trust anchor, got: %v", err) + } +} + +func TestVerifyRootZone_Invalid(t *testing.T) { + ta, err := NewTrustAnchor() + if err != nil { + t.Fatalf("NewTrustAnchor failed: %v", err) + } + + // Create a fake DNSKEY + fakeKey := &dns.DNSKEY{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeDNSKEY, + Class: dns.ClassINET, + }, + Flags: 257, + Protocol: 3, + Algorithm: 8, + PublicKey: "FakeKeyDataThatDoesNotMatchTrustAnchor==", + } + + // Create a mock root zone with a different (wrong) key + rootZone := &SignedZone{ + zone: ".", + dnskey: &RRSet{rrSet: []dns.RR{fakeKey}}, + ds: &RRSet{}, + pubKeyLookup: make(map[uint16]*dns.DNSKEY), + } + rootZone.addPubKey(fakeKey) + + err = ta.VerifyRootZone(*rootZone) + if err != ErrRootZoneNotTrusted { + t.Errorf("VerifyRootZone should fail with wrong key, expected ErrRootZoneNotTrusted, got: %v", err) + } +} + +func TestVerifyRootZone_NotRootZone(t *testing.T) { + ta, err := NewTrustAnchor() + if err != nil { + t.Fatalf("NewTrustAnchor failed: %v", err) + } + + // Try to verify a non-root zone + zone := &SignedZone{ + zone: "example.com.", + dnskey: &RRSet{}, + ds: &RRSet{}, + pubKeyLookup: make(map[uint16]*dns.DNSKEY), + } + + err = ta.VerifyRootZone(*zone) + if err == nil || err.Error() != "not a root zone" { + t.Errorf("VerifyRootZone should fail for non-root zone, got: %v", err) + } +} + +func TestKeysMatch(t *testing.T) { + key1 := &dns.DNSKEY{ + Flags: 257, + Protocol: 3, + Algorithm: 8, + PublicKey: "TestKeyData", + } + + key2 := &dns.DNSKEY{ + Flags: 257, + Protocol: 3, + Algorithm: 8, + PublicKey: "TestKeyData", + } + + key3 := &dns.DNSKEY{ + Flags: 257, + Protocol: 3, + Algorithm: 8, + PublicKey: "DifferentKeyData", + } + + if !keysMatch(key1, key2) { + t.Error("keysMatch should return true for identical keys") + } + + if keysMatch(key1, key3) { + t.Error("keysMatch should return false for different keys") + } +}