From a95a049963b97929179ad988a61a49a3e69b5ee2 Mon Sep 17 00:00:00 2001 From: Colin Vidal Date: Wed, 3 Dec 2025 14:20:20 +0100 Subject: [PATCH 1/3] add support for EDE 9 Extended DNS Error 9 (Missing DNSKEY) is now sent when a validating resolver attempts to validate a response but can't get the DNSKEY from the authoritative server of the zone, while the DS record is present in the parent zone. Note the EDE 9 is send as part of the proveunsecure flow, after the validator successfully fetched the DS of the zone from the parent. So if the DS is also missing, the EDE 9 won't be sent. --- lib/dns/validator.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/dns/validator.c b/lib/dns/validator.c index 1721f671c7..b3a0b0513d 100644 --- a/lib/dns/validator.c +++ b/lib/dns/validator.c @@ -2112,6 +2112,8 @@ validate_dnskey_dsset(dns_validator_t *val) { &keyrdata); if (result != ISC_R_SUCCESS) { validator_log(val, ISC_LOG_DEBUG(3), "no DNSKEY matching DS"); + validator_addede(val, DNS_EDE_DNSKEYMISSING, + "DNSKEY found but not matching DS"); return DNS_R_NOKEYMATCH; } @@ -3522,6 +3524,11 @@ proveunsecure(dns_validator_t *val, bool have_ds, bool have_dnskey, /* Couldn't complete insecurity proof. */ validator_log(val, ISC_LOG_DEBUG(3), "insecurity proof failed: %s", isc_result_totext(result)); + + if (val->type == dns_rdatatype_dnskey && val->rdataset == NULL) { + validator_addede(val, DNS_EDE_DNSKEYMISSING, "no DNSKEY found"); + } + return DNS_R_NOTINSECURE; out: From e856afa3b5bf8d9a963c9b730729127097e4f339 Mon Sep 17 00:00:00 2001 From: Colin Vidal Date: Wed, 3 Dec 2025 14:22:43 +0100 Subject: [PATCH 2/3] add system tests covering EDE 9 The authoritative server on "missing-dnskey." zone is ns2, the zone is initially signed normally, but then the DNSKEY are pulled out from the signed generated zone file. As a consequence, a quering the resolver ns4 returns a SERVFAIL with EDE9 as the chain of trust is broken: the DS is prsent in the parent zone (the root zone in ns1), but the DNSKEY is missing from the zone. A similar is "wrong-dnskey.", but here the zone is signed correctly, but the DS points to a different DNSKEY. Hence no supported matching DNSKEY record could be found for the child. --- bin/tests/system/dnssec/ns1/root.db.in | 6 +++ bin/tests/system/dnssec/ns1/sign.sh | 3 ++ .../system/dnssec/ns2/missing-dnskey.db.in | 24 ++++++++++++ bin/tests/system/dnssec/ns2/missing-ksk.db.in | 24 ++++++++++++ bin/tests/system/dnssec/ns2/named.conf.j2 | 15 ++++++++ bin/tests/system/dnssec/ns2/sign.sh | 37 +++++++++++++++++++ .../system/dnssec/ns2/wrong-dnskey.db.in | 24 ++++++++++++ bin/tests/system/dnssec/tests_validation.py | 16 ++++++++ 8 files changed, 149 insertions(+) create mode 100644 bin/tests/system/dnssec/ns2/missing-dnskey.db.in create mode 100644 bin/tests/system/dnssec/ns2/missing-ksk.db.in create mode 100644 bin/tests/system/dnssec/ns2/wrong-dnskey.db.in diff --git a/bin/tests/system/dnssec/ns1/root.db.in b/bin/tests/system/dnssec/ns1/root.db.in index a4c671b98e..0ae7c1031d 100644 --- a/bin/tests/system/dnssec/ns1/root.db.in +++ b/bin/tests/system/dnssec/ns1/root.db.in @@ -47,3 +47,9 @@ inconsistent. NS ns2.inconsistent. ns2.inconsistent. A 10.53.0.2 nsec-rrsigs-stripped. NS ns10.nsec-rrsigs-stripped. ns10.nsec-rrsigs-stripped. A 10.53.0.10 +ns.missing-dnskey. A 10.53.0.2 +missing-dnskey. NS ns.missing-dnskey. +ns.missing-ksk. A 10.53.0.2 +missing-ksk. NS ns.missing-ksk. +ns.wrong-dnskey. A 10.53.0.2 +wrong-dnskey. NS ns.wrong-dnskey. diff --git a/bin/tests/system/dnssec/ns1/sign.sh b/bin/tests/system/dnssec/ns1/sign.sh index 02c9e18320..02ea125ff0 100644 --- a/bin/tests/system/dnssec/ns1/sign.sh +++ b/bin/tests/system/dnssec/ns1/sign.sh @@ -33,6 +33,9 @@ cp "../ns2/dsset-peer-ns-spoof." . cp "../ns2/dsset-dnskey-rrsigs-stripped." . cp "../ns2/dsset-ds-rrsigs-stripped." . cp "../ns2/dsset-inconsistent." . +cp "../ns2/dsset-missing-dnskey." . +cp "../ns2/dsset-wrong-dnskey." . +cp "../ns2/dsset-missing-ksk." . grep "$DEFAULT_ALGORITHM_NUMBER [12] " "../ns2/dsset-algroll." >"dsset-algroll." cp "../ns6/dsset-optout-tld." . diff --git a/bin/tests/system/dnssec/ns2/missing-dnskey.db.in b/bin/tests/system/dnssec/ns2/missing-dnskey.db.in new file mode 100644 index 0000000000..e890ad5aed --- /dev/null +++ b/bin/tests/system/dnssec/ns2/missing-dnskey.db.in @@ -0,0 +1,24 @@ +; Copyright (C) Internet Systems Consortium, Inc. ("ISC") +; +; SPDX-License-Identifier: MPL-2.0 +; +; This Source Code Form is subject to the terms of the Mozilla Public +; License, v. 2.0. If a copy of the MPL was not distributed with this +; file, you can obtain one at https://mozilla.org/MPL/2.0/. +; +; See the COPYRIGHT file distributed with this work for additional +; information regarding copyright ownership. + +$TTL 300 +@ IN SOA mname1. . ( + 2000042407 ; serial + 20 ; refresh (20 seconds) + 20 ; retry (20 seconds) + 1814400 ; expire (3 weeks) + 3600 ; minimum (1 hour) + ) + NS ns + MX 10 mx +ns A 10.53.0.2 +a A 10.0.0.1 +b A 10.0.0.2 diff --git a/bin/tests/system/dnssec/ns2/missing-ksk.db.in b/bin/tests/system/dnssec/ns2/missing-ksk.db.in new file mode 100644 index 0000000000..e890ad5aed --- /dev/null +++ b/bin/tests/system/dnssec/ns2/missing-ksk.db.in @@ -0,0 +1,24 @@ +; Copyright (C) Internet Systems Consortium, Inc. ("ISC") +; +; SPDX-License-Identifier: MPL-2.0 +; +; This Source Code Form is subject to the terms of the Mozilla Public +; License, v. 2.0. If a copy of the MPL was not distributed with this +; file, you can obtain one at https://mozilla.org/MPL/2.0/. +; +; See the COPYRIGHT file distributed with this work for additional +; information regarding copyright ownership. + +$TTL 300 +@ IN SOA mname1. . ( + 2000042407 ; serial + 20 ; refresh (20 seconds) + 20 ; retry (20 seconds) + 1814400 ; expire (3 weeks) + 3600 ; minimum (1 hour) + ) + NS ns + MX 10 mx +ns A 10.53.0.2 +a A 10.0.0.1 +b A 10.0.0.2 diff --git a/bin/tests/system/dnssec/ns2/named.conf.j2 b/bin/tests/system/dnssec/ns2/named.conf.j2 index 093aba3120..8507c768f0 100644 --- a/bin/tests/system/dnssec/ns2/named.conf.j2 +++ b/bin/tests/system/dnssec/ns2/named.conf.j2 @@ -239,4 +239,19 @@ zone "child.ds-rrsigs-stripped" { file "child.ds-rrsigs-stripped.db.signed"; }; +zone "missing-dnskey" { + type primary; + file "missing-dnskey.db.signed"; +}; + +zone "missing-ksk" { + type primary; + file "missing-ksk.db.signed"; +}; + +zone "wrong-dnskey" { + type primary; + file "wrong-dnskey.db.signed"; +}; + include "trusted.conf"; diff --git a/bin/tests/system/dnssec/ns2/sign.sh b/bin/tests/system/dnssec/ns2/sign.sh index da9f5f07fc..0d168677ae 100644 --- a/bin/tests/system/dnssec/ns2/sign.sh +++ b/bin/tests/system/dnssec/ns2/sign.sh @@ -469,3 +469,40 @@ key1=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" -f KSK "$zone") key2=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") cat "$infile" "$key1.key" "$key2.key" >"$zonefile" "$SIGNER" -3 - -g -o "$zone" "$zonefile" >/dev/null 2>&1 + +# +# The DNSKEYs gets removed from the signed zone. +# +zone=missing-dnskey +infile=missing-dnskey.db.in +zonefile=missing-dnskey.db +key1=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") +key2=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" -f KSK "$zone") +cat "$infile" "$key1.key" "$key2.key" >"$zonefile" +"$SIGNER" -o "$zone" "$zonefile" >/dev/null 2>&1 +"$CHECKZONE" -D -q -i local "$zone" "$zonefile.signed" | awk '$4 == "DNSKEY" { next } { print }' >"$zonefile.stripped" +mv "$zonefile.stripped" "$zonefile.signed" + +# +# The KSK gets removed from the signed zone, but the ZSK is still there. +# 257 is the flag value indicating the key is the KSK +# +zone=missing-ksk +infile=missing-ksk.db.in +zonefile=missing-ksk.db +key1=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") +key2=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" -f KSK "$zone") +cat "$infile" "$key1.key" "$key2.key" >"$zonefile" +"$SIGNER" -o "$zone" "$zonefile" >/dev/null 2>&1 +"$CHECKZONE" -D -q -i local "$zone" "$zonefile.signed" | awk '$4 == "DNSKEY" && $5 == "257" { next } { print }' >"$zonefile.stripped" +mv "$zonefile.stripped" "$zonefile.signed" + +zone=wrong-dnskey +infile=wrong-dnskey.db.in +zonefile=wrong-dnskey.db +key1=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") +key2=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" -f KSK "$zone") +cat "$infile" "$key1.key" "$key2.key" >"$zonefile" +"$SIGNER" -o "$zone" "$zonefile" >/dev/null 2>&1 +key3=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" -f KSK "$zone") +$DSFROMKEY "$key3.key" >"dsset-$zone." diff --git a/bin/tests/system/dnssec/ns2/wrong-dnskey.db.in b/bin/tests/system/dnssec/ns2/wrong-dnskey.db.in new file mode 100644 index 0000000000..e890ad5aed --- /dev/null +++ b/bin/tests/system/dnssec/ns2/wrong-dnskey.db.in @@ -0,0 +1,24 @@ +; Copyright (C) Internet Systems Consortium, Inc. ("ISC") +; +; SPDX-License-Identifier: MPL-2.0 +; +; This Source Code Form is subject to the terms of the Mozilla Public +; License, v. 2.0. If a copy of the MPL was not distributed with this +; file, you can obtain one at https://mozilla.org/MPL/2.0/. +; +; See the COPYRIGHT file distributed with this work for additional +; information regarding copyright ownership. + +$TTL 300 +@ IN SOA mname1. . ( + 2000042407 ; serial + 20 ; refresh (20 seconds) + 20 ; retry (20 seconds) + 1814400 ; expire (3 weeks) + 3600 ; minimum (1 hour) + ) + NS ns + MX 10 mx +ns A 10.53.0.2 +a A 10.0.0.1 +b A 10.0.0.2 diff --git a/bin/tests/system/dnssec/tests_validation.py b/bin/tests/system/dnssec/tests_validation.py index 9446f73da1..2e8d94395a 100644 --- a/bin/tests/system/dnssec/tests_validation.py +++ b/bin/tests/system/dnssec/tests_validation.py @@ -1109,6 +1109,22 @@ def test_validating_forwarder(ns4, ns9): watcher.wait_for_line("status: SERVFAIL") +@pytest.mark.parametrize( + "zone", + [ + "missing-dnskey", + "wrong-dnskey", + "missing-ksk", + "a.extradsunknownoid.example", + ], +) +def test_missing_dnskey(zone, ns4): + msg = isctest.query.create(f"a.{zone}", "A") + res = isctest.query.tcp(msg, ns4.ip) + isctest.check.servfail(res) + isctest.check.ede(res, EDECode.DNSKEY_MISSING) + + def test_expired_signatures(ns4): # check expired signatures do not validate msg = isctest.query.create("expired.example", "SOA") From d07deba6158bb0cd16c02917350e79c3f75197c5 Mon Sep 17 00:00:00 2001 From: Colin Vidal Date: Tue, 16 Dec 2025 19:33:50 +0100 Subject: [PATCH 3/3] update SERVFAIL cache test An existing SERVFAIL cache test is updated as it initially checks there are no EDE (the first SERVFAIL) then immediately re-does the same query, (still SERVFAIL), and expect the CACHED_ERROR EDE. However, the configuration used for this test to generate a SERVFAIL is a broken DNSSEC configuration, where the DNSKEY is not the expected one (it's a ZSK instead of a KZK). As a result, the first attempt also now raise an EDE (MISSING_DNSKEY). --- bin/tests/system/sfcache/ns2/sign.sh | 4 ++++ bin/tests/system/sfcache/tests_sfcache.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bin/tests/system/sfcache/ns2/sign.sh b/bin/tests/system/sfcache/ns2/sign.sh index fa5cf50132..867c8b658f 100644 --- a/bin/tests/system/sfcache/ns2/sign.sh +++ b/bin/tests/system/sfcache/ns2/sign.sh @@ -20,6 +20,10 @@ zone=example. infile=example.db.in zonefile=example.db +# The zone is signed but it's broken: instead of having a ZSK and a KSK (which +# is the DNSKEY pointed by the parent's DS), it has two ZSKs. As a result, +# `example.` validations will always fail, resulting into a SERVFAIL on +# validating resolvers. keyname1=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") keyname2=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") diff --git a/bin/tests/system/sfcache/tests_sfcache.py b/bin/tests/system/sfcache/tests_sfcache.py index 4802fbafa9..bed8b36da9 100644 --- a/bin/tests/system/sfcache/tests_sfcache.py +++ b/bin/tests/system/sfcache/tests_sfcache.py @@ -20,9 +20,11 @@ def check_sfcache_ede(ns, ede): res = isctest.query.udp(msg, ns.ip) isctest.check.servfail(res) if ede: + # The SERVFAIL is cached, so now it shows up the EDE CACHED_ERROR, but not the DNSKEY_MISSING. isctest.check.ede(res, EDECode.CACHED_ERROR) else: - isctest.check.noede(res) + # example. domain DNSSEC is misconfigured on ns2, as it have two ZSK but no KSK. As a result, the DNSKEY for example. can't be found. + isctest.check.ede(res, EDECode.DNSKEY_MISSING) def test_sfcache_ede(ns5, templates):