279 lines
9.7 KiB
Go
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)
|
|
}
|