// Package dnssec performs real DNSSEC chain-of-trust validation — used by // internal/dane to decide whether a TLSA record can be trusted, replacing // a design that only checked the DNSSEC "AD" response flag. Built on // github.com/miekg/dns, a deliberate, disclosed exception to this // project's "no third-party protocol libraries" principle: miekg/dns // handles DNS wire format and — critically — RRSIG.Verify's signature // cryptography (RSA/ECDSA/EdDSA dispatch), the one piece of this whole // codebase judged too high-risk to hand-roll. A subtle bug in hand-rolled // chain validation either creates false security (silently accepting // forged records) or breaks mail delivery outright. // // Fail-closed by design: every ambiguous case (missing DS, missing // DNSKEY, a signature that doesn't verify, an expired signature, an // unrecognized digest type) returns an error, which callers must treat as // "not authenticated" — never as a reason to trust the data anyway. This // is safe because of how the one caller (internal/dane) uses it: no // authenticated TLSA record just means falling through to opportunistic/ // MTA-STS TLS, identical to today's behavior for any unsigned zone. A // false rejection here costs nothing but the DANE guarantee; a false // acceptance would mean trusting a forged certificate pin. This package // is built to only ever fail in the safe direction. // // Deliberately out of scope: NSEC/NSEC3 denial-of-existence proofs. // internal/dane only needs "if a TLSA record is asserted, is it validly // signed" — not a cryptographic proof that no record exists — so a broken // chain (no DS, unsigned zone) can simply mean "not authenticated" // without needing to parse NSEC/NSEC3 records at all. package dnssec import ( "context" "fmt" "net" "strings" "time" "github.com/miekg/dns" ) // rootTrustAnchor is IANA's published root zone KSK-2017 DS record — the // one axiom this package's chain of trust bottoms out at. Every DNSSEC- // validating resolver (unbound, BIND, Knot Resolver, ...) ships with this // same value. Published at data.iana.org/root-anchors/root-anchors.xml. var rootTrustAnchor = &dns.DS{ KeyTag: 20326, Algorithm: 8, // RSA/SHA-256 DigestType: dns.SHA256, Digest: "E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D", } var client = &dns.Client{Timeout: 10 * time.Second} func resolvers() []string { cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") if err != nil || cfg == nil || len(cfg.Servers) == 0 { return []string{"127.0.0.1:53"} } port := cfg.Port if port == "" { port = "53" } servers := make([]string, 0, len(cfg.Servers)) for _, s := range cfg.Servers { servers = append(servers, net.JoinHostPort(s, port)) } return servers } // query asks for qname/qtype with the DNSSEC OK (DO) bit set — without it, // most resolvers won't bother including RRSIG records in the response at // all, DNSSEC-validating or not. Checking Disabled (CD) is also set: this // package does its own validation and must not depend on the configured // resolver's — a validating resolver that filters bad signatures on our // behalf would make our own RRSIG.Verify calls untested dead code, and // (worse) a resolver an attacker controls could set the AD flag on // anything. CD=1 asks for the raw signed data every time, so a query // against a permissive resolver and a query against a strict validating // one are verified identically, by this code, not the resolver. func query(ctx context.Context, qname string, qtype uint16) (*dns.Msg, error) { m := new(dns.Msg) m.SetQuestion(dns.Fqdn(qname), qtype) m.SetEdns0(4096, true) m.RecursionDesired = true m.CheckingDisabled = true var lastErr error for _, server := range resolvers() { resp, _, err := client.ExchangeContext(ctx, m, server) if err != nil { lastErr = err continue } return resp, nil } return nil, fmt.Errorf("query %s %s failed against all resolvers: %w", qname, dns.TypeToString[qtype], lastErr) } func rrsetOf(msg *dns.Msg, rrtype uint16) []dns.RR { var out []dns.RR for _, rr := range msg.Answer { if rr.Header().Rrtype == rrtype { out = append(out, rr) } } return out } // rrsigsOf returns every RRSIG covering rrtype in msg's Answer section — an // RRset is commonly covered by more than one (e.g. both KSK and ZSK sign // the DNSKEY RRset, or two signatures coexist mid-rollover), so callers // must try each rather than assume the first one found is the relevant // one. func rrsigsOf(msg *dns.Msg, covering uint16) []*dns.RRSIG { var out []*dns.RRSIG for _, rr := range msg.Answer { if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == covering { out = append(out, sig) } } return out } // verifiedBy reports whether any of sigs both matches a key in keys (by // key tag) and verifies rrset under that key, within its validity period. func verifiedBy(sigs []*dns.RRSIG, keys []*dns.DNSKEY, rrset []dns.RR) bool { for _, sig := range sigs { for _, key := range keys { if key.KeyTag() != sig.KeyTag { continue } if sig.Verify(key, rrset) != nil { continue } if !sig.ValidityPeriod(time.Now()) { continue } return true } } return false } // validatedZoneKeys returns zone's DNSKEY RRset once its self-signature // (by a KSK whose digest matches ds) is verified. ds nil means zone is the // root, verified against the embedded rootTrustAnchor instead. func validatedZoneKeys(ctx context.Context, zone string, ds *dns.DS) ([]*dns.DNSKEY, error) { if ds == nil { ds = rootTrustAnchor } resp, err := query(ctx, zone, dns.TypeDNSKEY) if err != nil { return nil, err } dnskeyRRs := rrsetOf(resp, dns.TypeDNSKEY) if len(dnskeyRRs) == 0 { return nil, fmt.Errorf("no DNSKEY records for zone %q", zone) } sigs := rrsigsOf(resp, dns.TypeDNSKEY) if len(sigs) == 0 { return nil, fmt.Errorf("no RRSIG covering DNSKEY for zone %q", zone) } var keys []*dns.DNSKEY var matchedKSK *dns.DNSKEY for _, rr := range dnskeyRRs { key, ok := rr.(*dns.DNSKEY) if !ok { continue } keys = append(keys, key) if key.KeyTag() != ds.KeyTag { continue } if candidate := key.ToDS(ds.DigestType); candidate != nil && strings.EqualFold(candidate.Digest, ds.Digest) { matchedKSK = key } } if matchedKSK == nil { return nil, fmt.Errorf("no DNSKEY in zone %q matches the trusted DS (key tag %d)", zone, ds.KeyTag) } if !verifiedBy(sigs, []*dns.DNSKEY{matchedKSK}, dnskeyRRs) { return nil, fmt.Errorf("DNSKEY RRSIG verification failed for zone %q against the trusted key", zone) } return keys, nil } // validatedDS queries child's DS record, verified against the PARENT // zone's already-trusted keys (a DS is signed by the parent, not the // child — that's what makes it a delegation signer). Returns (nil, nil) // — not an error — when there's genuinely no DS: child isn't a separate // signed zone cut, the normal case for the overwhelming majority of // labels (e.g. "_tcp" or "_25" under a TLSA lookup are never their own // delegated zone). func validatedDS(ctx context.Context, child string, parentKeys []*dns.DNSKEY) (*dns.DS, error) { resp, err := query(ctx, child, dns.TypeDS) if err != nil { return nil, err } dsRRs := rrsetOf(resp, dns.TypeDS) if len(dsRRs) == 0 { return nil, nil } sigs := rrsigsOf(resp, dns.TypeDS) if len(sigs) == 0 { return nil, fmt.Errorf("DS record(s) for %q present but unsigned", child) } if !verifiedBy(sigs, parentKeys, dsRRs) { return nil, fmt.Errorf("DS record(s) for %q could not be verified against the parent zone's trusted keys", child) } ds, ok := dsRRs[0].(*dns.DS) if !ok { return nil, fmt.Errorf("DS answer for %q did not contain a DS record", child) } return ds, nil } // validatedRRset fetches qname/qtype and verifies it against zoneKeys — // the last step, after the chain of trust has been walked down to the // zone that actually owns qname. func validatedRRset(ctx context.Context, qname string, qtype uint16, zoneKeys []*dns.DNSKEY) ([]dns.RR, error) { resp, err := query(ctx, qname, qtype) if err != nil { return nil, err } rrset := rrsetOf(resp, qtype) if len(rrset) == 0 { return nil, fmt.Errorf("no %s records for %q", dns.TypeToString[qtype], qname) } sigs := rrsigsOf(resp, qtype) if len(sigs) == 0 { return nil, fmt.Errorf("%s record(s) for %q present but unsigned", dns.TypeToString[qtype], qname) } if !verifiedBy(sigs, zoneKeys, rrset) { return nil, fmt.Errorf("%s record(s) for %q could not be verified against the zone's trusted keys", dns.TypeToString[qtype], qname) } return rrset, nil } // Validate performs full DNSSEC chain validation for qname/qtype: starting // from the embedded root trust anchor, it walks down every label of qname, // checking for a real zone cut (a DS record) at each boundary — no DS // simply means "still the same signing zone," not a failure — until it // reaches the zone that actually owns qname, then verifies qname/qtype's // own signature against that zone's validated keys. Returns the validated // RRset only on a fully unbroken chain; any break returns an error. func Validate(ctx context.Context, qname string, qtype uint16) ([]dns.RR, error) { qname = dns.Fqdn(qname) keys, err := validatedZoneKeys(ctx, ".", nil) if err != nil { return nil, fmt.Errorf("root zone: %w", err) } labels := dns.SplitDomainName(qname) zone := "." for i := len(labels) - 1; i >= 0; i-- { var child string if zone == "." { child = labels[i] + "." } else { child = labels[i] + "." + zone } ds, err := validatedDS(ctx, child, keys) if err != nil { return nil, fmt.Errorf("zone cut at %q: %w", child, err) } if ds != nil { childKeys, err := validatedZoneKeys(ctx, child, ds) if err != nil { return nil, fmt.Errorf("zone %q: %w", child, err) } keys = childKeys } zone = child } return validatedRRset(ctx, qname, qtype, keys) }