coredns-rfc2136/tsig.go

105 lines
4.2 KiB
Go
Raw Permalink Normal View History

Phase 1.4: UPDATE opcode handler + TSIG verification Replaces the Phase-1.3 refuseUpdate() stub with a real RFC 2136 handler. Caddy via caddy-dns/rfc2136 can now inject and remove records. UPDATE message handling (update.go): - Zone section validation: must be exactly one SOA-typed record naming a zone we're authoritative for. Returns FORMERR/NOTAUTH otherwise. - Prerequisites (§3.2): name-exists, RRset-exists, name-NOT-exists, RRset-NOT-exists semantics implemented. First failure short-circuits with the spec's rcode (NXDOMAIN/NXRRSET/YXDOMAIN/YXRRSET). - Updates (§3.4.2): add RR, delete RRset (CLASS=ANY+RDLEN=0), delete all RRsets at name (CLASS=ANY+TYPE=ANY), delete specific RR (CLASS= NONE). - Apex SOA/NS protected: synthetic and cannot be added or removed via UPDATE. Apex wipe (TYPE=ANY at apex) also refused. - Default TTL applied to incoming records with TTL=0. TSIG (tsig.go + setup.go): - setup() now populates dnsserver.Config.TsigSecret so the underlying dns.Server auto-verifies signatures via miekg/dns. - checkTSIG() in ServeDNS gates UPDATEs: rejects if no TSIG, unknown key name, algorithm-downgrade attempt, or w.TsigStatus() != nil. - No TSIG keys configured → all UPDATEs refused (safety default). - Algorithm pinning prevents downgrade attacks (e.g. forced HMAC-MD5). Tests (update_test.go): 11 new cases covering happy paths and every error rcode. Total: 35 top-level test passes, 0 failures. ServeDNS dispatch now calls handleUpdate after auth gate. The refuseUpdate() stub is gone. UPDATE end-to-end via nsupdate requires the custom CoreDNS image (Phase 2) to verify TSIG plumbing on the dns.Server side.
2026-05-21 10:51:18 -06:00
package rfc2136
import (
"fmt"
"strings"
"time"
Phase 1.4: UPDATE opcode handler + TSIG verification Replaces the Phase-1.3 refuseUpdate() stub with a real RFC 2136 handler. Caddy via caddy-dns/rfc2136 can now inject and remove records. UPDATE message handling (update.go): - Zone section validation: must be exactly one SOA-typed record naming a zone we're authoritative for. Returns FORMERR/NOTAUTH otherwise. - Prerequisites (§3.2): name-exists, RRset-exists, name-NOT-exists, RRset-NOT-exists semantics implemented. First failure short-circuits with the spec's rcode (NXDOMAIN/NXRRSET/YXDOMAIN/YXRRSET). - Updates (§3.4.2): add RR, delete RRset (CLASS=ANY+RDLEN=0), delete all RRsets at name (CLASS=ANY+TYPE=ANY), delete specific RR (CLASS= NONE). - Apex SOA/NS protected: synthetic and cannot be added or removed via UPDATE. Apex wipe (TYPE=ANY at apex) also refused. - Default TTL applied to incoming records with TTL=0. TSIG (tsig.go + setup.go): - setup() now populates dnsserver.Config.TsigSecret so the underlying dns.Server auto-verifies signatures via miekg/dns. - checkTSIG() in ServeDNS gates UPDATEs: rejects if no TSIG, unknown key name, algorithm-downgrade attempt, or w.TsigStatus() != nil. - No TSIG keys configured → all UPDATEs refused (safety default). - Algorithm pinning prevents downgrade attacks (e.g. forced HMAC-MD5). Tests (update_test.go): 11 new cases covering happy paths and every error rcode. Total: 35 top-level test passes, 0 failures. ServeDNS dispatch now calls handleUpdate after auth gate. The refuseUpdate() stub is gone. UPDATE end-to-end via nsupdate requires the custom CoreDNS image (Phase 2) to verify TSIG plumbing on the dns.Server side.
2026-05-21 10:51:18 -06:00
"github.com/miekg/dns"
)
// tsigResponseFudge is the time tolerance (seconds) embedded in
// responses we sign. RFC 8945 §10 suggests 300s; we mirror that.
const tsigResponseFudge = 300
// signResponseIfSigned attaches a TSIG record to resp using the
// request's key name and algorithm. This causes the downstream
// dns.ResponseWriter to compute and serialize the MAC at WriteMsg
// time (using the secret from the server's TsigSecret map, which
// setup.go populated). Per RFC 8945 §5.4.2, the response to a
// TSIG-signed message MUST itself be signed if the server knows the
// key — otherwise the client cannot authenticate the answer and
// rejects it with "expected a TSIG or SIG(0)" (BIND nsupdate's exact
// complaint).
//
// If the request was not TSIG-signed, this is a no-op. If the key is
// not in the server's TsigSecret map (e.g. unknown key), miekg/dns
// will skip signing at write time and the response goes back
// unsigned — that's the correct shape for "I don't have your key."
func signResponseIfSigned(resp, req *dns.Msg) {
tsig := req.IsTsig()
if tsig == nil {
return
}
resp.SetTsig(tsig.Hdr.Name, tsig.Algorithm, tsigResponseFudge, time.Now().Unix())
}
Phase 1.4: UPDATE opcode handler + TSIG verification Replaces the Phase-1.3 refuseUpdate() stub with a real RFC 2136 handler. Caddy via caddy-dns/rfc2136 can now inject and remove records. UPDATE message handling (update.go): - Zone section validation: must be exactly one SOA-typed record naming a zone we're authoritative for. Returns FORMERR/NOTAUTH otherwise. - Prerequisites (§3.2): name-exists, RRset-exists, name-NOT-exists, RRset-NOT-exists semantics implemented. First failure short-circuits with the spec's rcode (NXDOMAIN/NXRRSET/YXDOMAIN/YXRRSET). - Updates (§3.4.2): add RR, delete RRset (CLASS=ANY+RDLEN=0), delete all RRsets at name (CLASS=ANY+TYPE=ANY), delete specific RR (CLASS= NONE). - Apex SOA/NS protected: synthetic and cannot be added or removed via UPDATE. Apex wipe (TYPE=ANY at apex) also refused. - Default TTL applied to incoming records with TTL=0. TSIG (tsig.go + setup.go): - setup() now populates dnsserver.Config.TsigSecret so the underlying dns.Server auto-verifies signatures via miekg/dns. - checkTSIG() in ServeDNS gates UPDATEs: rejects if no TSIG, unknown key name, algorithm-downgrade attempt, or w.TsigStatus() != nil. - No TSIG keys configured → all UPDATEs refused (safety default). - Algorithm pinning prevents downgrade attacks (e.g. forced HMAC-MD5). Tests (update_test.go): 11 new cases covering happy paths and every error rcode. Total: 35 top-level test passes, 0 failures. ServeDNS dispatch now calls handleUpdate after auth gate. The refuseUpdate() stub is gone. UPDATE end-to-end via nsupdate requires the custom CoreDNS image (Phase 2) to verify TSIG plumbing on the dns.Server side.
2026-05-21 10:51:18 -06:00
// checkTSIG verifies that the incoming UPDATE message is properly signed
// with a TSIG key we know about. The actual signature math has already
// been done by the underlying dns.Server (because setup.go registered
// our keys in dnsserver.Config.TsigSecret); this function just inspects
// the result and the key identity.
//
// Behavior matrix:
//
// No TSIG keys configured → updates are unauthenticated. Caller may
// still allow if it deems the network safe;
// we conservatively reject (REFUSED) since
// the practical use case (Caddy) always
// signs.
// TSIG keys configured but message has no TSIG → reject.
// TSIG present, key name not in our map → reject.
// TSIG present, signature failed at dns.Server → reject (TsigStatus()).
// All good → nil.
func (p *RFC2136) checkTSIG(w dns.ResponseWriter, r *dns.Msg) error {
tsig := r.IsTsig()
if len(p.TSIGKeys) == 0 {
return fmt.Errorf("no TSIG keys configured; refusing all UPDATEs as a safety default")
}
if tsig == nil {
return fmt.Errorf("TSIG required but not present")
}
keyName := strings.ToLower(tsig.Hdr.Name)
if !strings.HasSuffix(keyName, ".") {
keyName += "."
}
key, known := p.TSIGKeys[keyName]
if !known {
return fmt.Errorf("unknown TSIG key %q", keyName)
}
// Algorithm pinning: the incoming TSIG can in theory use any
// algorithm miekg/dns supports, but we only honour the one declared
// in Corefile. Rejecting algorithm-downgrade attempts is a small
// but important hardening — without this, an attacker who somehow
// got a key could downgrade to HMAC-MD5 (which we don't even
// configure but miekg/dns understands).
if !strings.EqualFold(tsig.Algorithm, key.Algorithm) {
return fmt.Errorf("TSIG algorithm mismatch: incoming=%s expected=%s", tsig.Algorithm, key.Algorithm)
}
// The underlying dns.Server verifies the TSIG MAC for us when it
// has the secret in its TsigSecret map (which setup.go wires up).
// A nil from TsigStatus means verification succeeded; any non-nil
// error means the signature was invalid, the time was outside the
// fudge window, or some other auth failure.
H6/H7/M3/M4/M7: hardening + behavior documentation H6 — TSIG replay-window test. New TestCheckTSIG_BadStatus_Refused verifies that when miekg/dns reports a TSIG verification failure via ResponseWriter.TsigStatus (the channel for fudge-window violations, bad MACs, expired timestamps), our plugin refuses. The fudge tolerance itself is miekg/dns's default (300s); documented in tsig.go so operators know the dependency. H7 — No-op UPDATE policy: documented explicitly in update.go. We do NOT bump the SOA on a no-op (deduped) UPDATE — forcing downstream secondaries to AXFR identical content wastes bandwidth and contradicts RFC 2136's intent. Callers wanting to force a serial bump can send a throwaway add+delete pair (touch-UPDATE pattern). M3 — Delete-by-exact-match ignores TTL and class per RFC 2136 §2.5.4. The previous rr.String() comparison included TTL, so an UPDATE with CLASS=NONE TTL=0 (the protocol-required encoding for a delete) failed to match stored RRs at CLASS=IN with non-zero TTL. Now we normalize both sides (TTL=0, class=IN) before invoking dns.IsDuplicate. M4 — validateZoneFiles now actually parses each zone at startup (loadRRs invocation). Previously it only stat()'d the file; corrupt zone content sailed through startup and produced SERVFAIL on the first UPDATE with no startup-time signal. Combined with H3+H4's invariant checks, this turns silent zone corruption into immediate startup failure. M7 — Commit-message sanitization. RR names are attacker-controlled (TSIG only authenticates the sender; the payload is hostile by default). Control characters in commit messages could inject newlines into git log or ANSI sequences into downstream log renderers. New sanitizeForCommitMessage escapes \n, \r, \t, and other C0 controls. New tests: - TestCheckTSIG_BadStatus_Refused (H6) - TestUpdate_DeleteRR_IgnoresTTL (M3) - TestSanitizeForCommitMessage (M7)
2026-05-22 21:29:13 -06:00
//
// H6 — Replay window: the TSIG `fudge` field (RFC 8945 §5.2) is the
// allowed clock skew between client and server. miekg/dns enforces
// this in TsigVerify (rejecting with ErrTime if the request's time
// is too far from local time). We rely on miekg/dns's default
// tolerance (currently 300s); we do not configure it ourselves.
// If miekg/dns's default ever changes, our replay-window behavior
// changes with it — operators should monitor upstream release notes.
// A future enhancement is to make the fudge configurable via the
// Corefile (e.g., `tsig-fudge 300`).
Phase 1.4: UPDATE opcode handler + TSIG verification Replaces the Phase-1.3 refuseUpdate() stub with a real RFC 2136 handler. Caddy via caddy-dns/rfc2136 can now inject and remove records. UPDATE message handling (update.go): - Zone section validation: must be exactly one SOA-typed record naming a zone we're authoritative for. Returns FORMERR/NOTAUTH otherwise. - Prerequisites (§3.2): name-exists, RRset-exists, name-NOT-exists, RRset-NOT-exists semantics implemented. First failure short-circuits with the spec's rcode (NXDOMAIN/NXRRSET/YXDOMAIN/YXRRSET). - Updates (§3.4.2): add RR, delete RRset (CLASS=ANY+RDLEN=0), delete all RRsets at name (CLASS=ANY+TYPE=ANY), delete specific RR (CLASS= NONE). - Apex SOA/NS protected: synthetic and cannot be added or removed via UPDATE. Apex wipe (TYPE=ANY at apex) also refused. - Default TTL applied to incoming records with TTL=0. TSIG (tsig.go + setup.go): - setup() now populates dnsserver.Config.TsigSecret so the underlying dns.Server auto-verifies signatures via miekg/dns. - checkTSIG() in ServeDNS gates UPDATEs: rejects if no TSIG, unknown key name, algorithm-downgrade attempt, or w.TsigStatus() != nil. - No TSIG keys configured → all UPDATEs refused (safety default). - Algorithm pinning prevents downgrade attacks (e.g. forced HMAC-MD5). Tests (update_test.go): 11 new cases covering happy paths and every error rcode. Total: 35 top-level test passes, 0 failures. ServeDNS dispatch now calls handleUpdate after auth gate. The refuseUpdate() stub is gone. UPDATE end-to-end via nsupdate requires the custom CoreDNS image (Phase 2) to verify TSIG plumbing on the dns.Server side.
2026-05-21 10:51:18 -06:00
if status := w.TsigStatus(); status != nil {
return fmt.Errorf("TSIG verification failed for key %q: %w", keyName, status)
}
return nil
}