From: Petr Špaček Date: Thu, 5 Jun 2025 13:15:08 +0000 (+0200) Subject: Split NXDOMAIN/NOERROR/NODATA test cases X-Git-Tag: v9.21.11~22^2~12 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=9ca2077274908d86599e0161cf2c0ccc140b224f;p=thirdparty%2Fbind9.git Split NXDOMAIN/NOERROR/NODATA test cases Untangling individual cases allows for clearer documentation and makes it easier to build similar but slightly different test cases. Wildcard NODATA answer was added. --- diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index f7f98be3646..c7824abf0b0 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -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. + # ., 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)