Files
gomail/internal/dnssec/dnssec.go
T
2026-08-10 21:15:19 +01:00

279 lines
9.7 KiB
Go

// 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)
}