]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Split NXDOMAIN/NOERROR/NODATA test cases
authorPetr Špaček <pspacek@isc.org>
Thu, 5 Jun 2025 13:15:08 +0000 (15:15 +0200)
committerPetr Špaček <pspacek@isc.org>
Tue, 29 Jul 2025 08:00:45 +0000 (10:00 +0200)
Untangling individual cases allows for clearer documentation and makes
it easier to build similar but slightly different test cases.  Wildcard
NODATA answer was added.

bin/tests/system/dnssec/tests_nsec3.py

index f7f98be3646c5b34b22d388486cbd405ddfd5ea8..c7824abf0b0a6b80a56ef04ebc93537fc833d28b 100755 (executable)
@@ -25,6 +25,7 @@ import dns.query
 import dns.rcode
 import dns.rdataclass
 import dns.rdatatype
+import dns.rdtypes.ANY.RRSIG
 import dns.rrset
 
 from isctest.hypothesis.strategies import dns_names
@@ -42,13 +43,36 @@ ZONE = isctest.name.ZoneAnalyzer.read_path(
 )
 
 
+def do_test_query(qname, qtype, server, named_port) -> dns.message.Message:
+    query = dns.message.make_query(qname, qtype, use_edns=True, want_dnssec=True)
+    response = isctest.query.tcp(query, server, named_port, timeout=TIMEOUT)
+    isctest.check.is_response_to(response, query)
+    assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN)
+    return response
+
+
+def assume_nx_and_no_delegation(qname):
+    assume(qname not in ZONE.all_existing_names)
+
+    # name must not be under a delegation or DNAME:
+    # it would not work with resolver ns4
+    assume(
+        not isctest.name.is_related_to_any(
+            qname,
+            (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN),
+            ZONE.reachable_delegations.union(ZONE.reachable_dnames),
+        )
+    )
+
+
 def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool:
     """
-    Test if 'hashed_name' is covered by an NSEC3 record in 'rrset'.
+    Test if 'hashed_name' is covered by an NSEC3 record in 'rrset', i.e. the name does not exist.
     """
     prev_name = rrset.name
 
     for nsec3 in rrset:
+        assert nsec3.flags == 0, "opt-out not supported by test logic"
         next_name = nsec3.next_name(SUFFIX)
 
         # Single name case.
@@ -72,6 +96,7 @@ def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool:
 
 
 def check_nsec3_covers(name: dns.name.Name, response: dns.message.Message) -> None:
+    """Given name provably does not exist"""
     name_is_covered = False
 
     nhash = dns.dnssec.nsec3_hash(
@@ -93,89 +118,135 @@ def check_nsec3_covers(name: dns.name.Name, response: dns.message.Message) -> No
 @pytest.mark.parametrize(
     "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")]
 )
-@given(name=dns_names(suffix=SUFFIX))
-# @given(name=just(dns.name.from_text(f"\000.\001.{SUFFIX}")))
-# @given(name=just(dns.name.from_text(f"a.wild.{SUFFIX}")))
-def test_dnssec_nsec3_nxdomain(server, name: dns.name.Name, named_port: int) -> None:
-    noqname_test(server, name, named_port)
+@given(qname=dns_names(suffix=SUFFIX))
+def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None:
+    """A real NXDOMAIN, no wildcards involved"""
+    assume_nx_and_no_delegation(qname)
+    wname = ZONE.source_of_synthesis(qname)
+    assume(wname not in ZONE.reachable_wildcards)
+
+    check_nxdomain(server, named_port, qname)
 
 
 @pytest.mark.parametrize(
     "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")]
 )
-@given(name=dns_names(suffix=ZONE.reachable.union(ZONE.ents)))
-def test_dnssec_nsec3_subdomain_nxdomain(
-    server, name: dns.name.Name, named_port: int
-) -> None:
-    noqname_test(server, name, named_port)
+@given(qname=dns_names(suffix=ZONE.ents))
+def test_ents(server, qname: dns.name.Name, named_port: int) -> None:
+    """ENT can have a wildcard under it"""
+    assume_nx_and_no_delegation(qname)
 
+    wname = ZONE.source_of_synthesis(qname)
+    # does qname match a wildcard under ENT?
+    if wname in ZONE.reachable_wildcards:
+        check_wildcard_synthesis(server, named_port, qname)
+    else:
+        check_nxdomain(server, named_port, qname)
 
-def noqname_test(server, name: dns.name.Name, named_port: int) -> None:
-    # randomly generated name must not exist
-    assume(name not in (ZONE.all_existing_names))
 
-    # name must not be under a delegation or DNAME:
-    # it would not work with resolver ns4
-    assume(
-        not isctest.name.is_related_to_any(
-            name,
-            (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN),
-            ZONE.reachable_delegations.union(ZONE.reachable_dnames),
-        )
-    )
+@pytest.mark.parametrize(
+    "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")]
+)
+@given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents))
+def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> None:
+    assume(qname not in ZONE.all_existing_names)
+
+    wname = ZONE.source_of_synthesis(qname)
+    assume(wname in ZONE.reachable_wildcards)
 
-    query = dns.message.make_query(
-        name, dns.rdatatype.A, use_edns=True, want_dnssec=True
+    check_wildcard_synthesis(server, named_port, qname)
+
+
+@pytest.mark.parametrize(
+    "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")]
+)
+@given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents))
+def test_wildcard_nodata(server, qname: dns.name.Name, named_port: int) -> None:
+    assume(qname not in ZONE.all_existing_names)
+
+    wname = ZONE.source_of_synthesis(qname)
+    assume(wname in ZONE.reachable_wildcards)
+
+    check_wildcard_nodata(server, named_port, qname)
+
+
+def check_nsec3_owner(owner: dns.name.Name, response):
+    """Check response has NSEC3 RR matching given owner name, i.e. the name exists."""
+    name_hash = dns.dnssec.nsec3_hash(
+        owner, salt=None, iterations=0, algorithm=NSEC3Hash.SHA1
     )
-    response = isctest.query.tcp(query, server, named_port, timeout=TIMEOUT)
-    isctest.check.is_response_to(response, query)
-    assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN)
+    nsec3_owner = dns.name.from_text(name_hash, SUFFIX)
+
+    nsec3_found = False
+    for rrset in response.authority:
+        if rrset.match(
+            nsec3_owner, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
+        ):
+            nsec3_found = True
+    assert (
+        nsec3_found
+    ), f"Expected matching NSEC3 for {owner} (hash={name_hash}) not found:\n{response}"
+
 
-    ce, nce = ZONE.closest_encloser(name)
-    # Response has NSEC3 that covers the next closer name
+def check_wildcard_nodata(server, named_port: int, qname: dns.name.Name) -> None:
+    response = do_test_query(qname, dns.rdatatype.AAAA, server, named_port)
+    assert response.rcode() is dns.rcode.NOERROR
+
+    ce, nce = ZONE.closest_encloser(qname)
+    check_nsec3_owner(ce, response)
     check_nsec3_covers(nce, response)
 
-    wname = ZONE.source_of_synthesis(name)
-    if wname in ZONE.reachable_wildcards:
-        wname_parent = dns.name.Name(wname[1:])
-        assert name.is_subdomain(wname_parent)
-        # expecting wildcard response with a signed A RRset
-        assert response.rcode() is dns.rcode.NOERROR
-        answer_sig = response.get_rrset(
-            section="ANSWER",
-            name=name,
-            rdclass=dns.rdataclass.IN,
-            rdtype=dns.rdatatype.RRSIG,
-            covers=dns.rdatatype.A,
-        )
-        assert answer_sig is not None
-        assert len(answer_sig) == 1
-        # RRSIG labels field, RFC 4034 section 3.1.3 does not count:
-        # - root label
-        # - leftmost * label
-        wildcard_parent_labels = answer_sig[0].labels + 1  # add root but not leftmost *
-        assert wildcard_parent_labels < len(name)
-        # ce should be wildcard name w/o wildcard label, nce one label longer
-        assert ce == name.split(wildcard_parent_labels)[1]
-        assert nce == name.split(wildcard_parent_labels + 1)[1]
-    else:
-        # no wildcard synthesis -> NXDOMAIN
-        assert response.rcode() is dns.rcode.NXDOMAIN
-        # Response must have closest encloser NSEC3
-        ce_hash = dns.dnssec.nsec3_hash(
-            ce, salt=None, iterations=0, algorithm=NSEC3Hash.SHA1
-        )
-        ce_nsec3 = dns.name.from_text(ce_hash, SUFFIX)
-
-        ce_nsec3_match = False
-        for rrset in response.authority:
-            if rrset.match(
-                ce_nsec3, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
-            ):
-                ce_nsec3_match = True
-        assert (
-            ce_nsec3_match
-        ), f"Expected matching NSEC3 for {ce} (hash={ce_hash}) not found:\n {response}"
-
-        # Response has NSEC3 that covers the wildcard
-        check_nsec3_covers(wname, response)
+    wname = ZONE.source_of_synthesis(qname)
+    # expecting proof that wildcard owner does not have rdatatype requested
+    check_nsec3_owner(wname, response)
+
+
+def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None:
+    response = do_test_query(qname, dns.rdatatype.A, server, named_port)
+    assert response.rcode() is dns.rcode.NXDOMAIN
+
+    ce, nce = ZONE.closest_encloser(qname)
+    check_nsec3_owner(ce, response)
+    check_nsec3_covers(nce, response)
+
+    wname = ZONE.source_of_synthesis(qname)
+    check_nsec3_covers(wname, response)
+
+
+def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> None:
+    """Expect wildcard response with a signed A RRset"""
+    response = do_test_query(qname, dns.rdatatype.A, server, named_port)
+    assert response.rcode() is dns.rcode.NOERROR
+
+    answer_sig = response.get_rrset(
+        section="ANSWER",
+        name=qname,
+        rdclass=dns.rdataclass.IN,
+        rdtype=dns.rdatatype.RRSIG,
+        covers=dns.rdatatype.A,
+    )
+    assert answer_sig is not None
+    assert len(answer_sig) == 1
+    rrsig = answer_sig[0]
+    assert isinstance(rrsig, dns.rdtypes.ANY.RRSIG.RRSIG)
+    # RRSIG labels field RFC 4034 section 3.1.3 does not count:
+    # - root label
+    # - leftmost * label
+    wildcard_parent_labels = rrsig.labels + 1  # add root but not leftmost *
+    assert wildcard_parent_labels < len(qname)
+
+    # 1. We have RRSIG from the wildcard '*.something', which proves the node
+    # 'something' exists (by definition - it has a child, so it exists, but
+    # maybe it is an ENT). Thus we expect closest encloser = 'something'
+    # 2. If wildcard synthesis is legitimate, QNAME itself and no nodes between
+    # QNAME and the closest encloser can exist. Because of DNS node existence
+    # rules it's sufficient to prove non-existence of next-closer name, i.e.
+    # <one_label_under>.<closest_encloser>, to deny existence of the whole
+    # subtree down to QNAME.
+
+    ce, nce = ZONE.closest_encloser(qname)
+    assert ce == qname.split(wildcard_parent_labels)[1]
+    # ce is proven to exist by the RRSIG
+    assert nce == qname.split(wildcard_parent_labels + 1)[1]
+    # nce must be proven to NOT exist
+    check_nsec3_covers(nce, response)