]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Extract closest encloser and source of synthesis logic into ZoneAnalyzer
authorPetr Špaček <pspacek@isc.org>
Tue, 3 Jun 2025 15:20:54 +0000 (17:20 +0200)
committerPetr Špaček <pspacek@isc.org>
Tue, 29 Jul 2025 08:00:45 +0000 (10:00 +0200)
As a side-effect, we now have set of all existing names in a zone with a
test, too. These parts should be shared with new NSEC tests.

bin/tests/system/dnssec/tests_nsec3.py
bin/tests/system/isctest/name.py
bin/tests/system/selftest/tests_zone_analyzer.py

index 1f01074edb24f4b9406d69daf7e7f63e61b06981..f7f98be3646c5b34b22d388486cbd405ddfd5ea8 100755 (executable)
@@ -111,13 +111,11 @@ def test_dnssec_nsec3_subdomain_nxdomain(
 
 
 def noqname_test(server, name: dns.name.Name, named_port: int) -> None:
-    # Name must not exist.
-    all_existing_names = (
-        ZONE.reachable.union(ZONE.ents).union(ZONE.delegations).union(ZONE.dnames)
-    )
-    assume(name not in (all_existing_names))
+    # randomly generated name must not exist
+    assume(name not in (ZONE.all_existing_names))
 
-    # Name must not be below a delegation or DNAME.
+    # 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,
@@ -133,11 +131,16 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None:
     isctest.check.is_response_to(response, query)
     assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN)
 
-    # Retrieve closest encloser (ce) and next closest encloser (nce).
-    ce = None
-    nce = None
-    if response.rcode() is dns.rcode.NOERROR:
-        # this should only be a wild card response
+    ce, nce = ZONE.closest_encloser(name)
+    # Response has NSEC3 that covers the next closer name
+    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,
@@ -147,26 +150,18 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None:
         )
         assert answer_sig is not None
         assert len(answer_sig) == 1
-        # root label is not being counted in labels field, RFC 4034 section 3.1.3
-        ce_labels = answer_sig[0].labels + 1
-        # wildcard labels < QNAME labels
-        assert ce_labels < len(name.labels)
-        # ce is wildcard name w/o wildcard label
-        _, ce = name.split(ce_labels)
-        _, nce = name.split(ce_labels + 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:
-        ce_labels = 0
-        for zname in all_existing_names:
-            relation, _, nlabels = name.fullcompare(zname)
-            if relation == dns.name.NameRelation.SUBDOMAIN:
-                if nlabels > ce_labels:
-                    ce_labels = nlabels
-                    ce = zname
-                    _, nce = name.split(ce_labels + 1)
-        assert ce is not None
-        assert nce is not None
-
-        # Response has closest encloser NSEC3.
+        # 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
         )
@@ -182,18 +177,5 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None:
             ce_nsec3_match
         ), f"Expected matching NSEC3 for {ce} (hash={ce_hash}) not found:\n {response}"
 
-    # Response has NSEC3 that covers the next closer name.
-    check_nsec3_covers(nce, response)
-
-    wc = dns.name.from_text("*", ce)
-    if response.rcode() is dns.rcode.NOERROR:
-        # only NOERRORs should be from wildcards
-        found_wc = False
-        for wildcard in ZONE.reachable_wildcards:
-            if wildcard == wc:
-                found_wc = True
-        assert found_wc
-
-    if response.rcode() == dns.rcode.NXDOMAIN:
-        # Response has NSEC3 that covers the wildcard.
-        check_nsec3_covers(wc, response)
+        # Response has NSEC3 that covers the wildcard
+        check_nsec3_covers(wname, response)
index 75c22720f680d7dd72d043fe5d7293271ee57f8f..255150e8372fc4ede168a532537ac75a04296cc6 100644 (file)
@@ -46,6 +46,7 @@ class ZoneAnalyzer:
       - have NS RR on it, are not zone's apex, and are not occluded
     - reachable_dnames - have DNAME RR on it and are not occluded
     - reachable_wildcards - have leftmost label '*' and are not occluded
+    - reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped
 
     Warnings:
     - Quadratic complexity ahead! Use only on small test zones.
@@ -73,6 +74,16 @@ class ZoneAnalyzer:
         self.ents = self.generate_ents()
         self.reachable_dnames = self.dnames.intersection(self.reachable)
         self.reachable_wildcards = self.wildcards.intersection(self.reachable)
+        self.reachable_wildcard_parents = {
+            Name(wname[1:]) for wname in self.reachable_wildcards
+        }
+
+        # (except for wildcard expansions) all names in zone which result in NOERROR answers
+        self.all_existing_names = (
+            self.reachable.union(self.ents)
+            .union(self.reachable_delegations)
+            .union(self.reachable_dnames)
+        )
 
     def get_names_with_type(self, rdtype) -> FrozenSet[Name]:
         return frozenset(
@@ -155,6 +166,31 @@ class ZoneAnalyzer:
 
         return frozenset(ents)
 
+    def closest_encloser(self, qname: Name):
+        """
+        Get (closest encloser, next closer name) for given qname.
+        """
+        ce = None  # Closest encloser, RFC 4592
+        nce = None  # Next closer name, RFC 5155
+        for zname in self.all_existing_names:
+            relation, _, common_labels = qname.fullcompare(zname)
+            if relation == NameRelation.SUBDOMAIN:
+                if not ce or common_labels > len(ce):
+                    # longest match so far
+                    ce = zname
+                    _, nce = qname.split(len(ce) + 1)
+        assert ce is not None
+        assert nce is not None
+        return ce, nce
+
+    def source_of_synthesis(self, qname: Name) -> Name:
+        """
+        Return source of synthesis according to RFC 4592 section 3.3.1.
+        Name is not guaranteed to exist or be reachable.
+        """
+        ce, _ = self.closest_encloser(qname)
+        return Name("*") + ce
+
 
 def is_related_to_any(
     test_name: Name,
index 9115d3ca64bc9a6e64ec6f5b9dc5a15c54cd6f3b..9cea8c7986ed5a80098f100f28c62f9595420b20 100755 (executable)
@@ -29,6 +29,7 @@ import isctest.name
 # set of properies present in the tested zone - read by tests_zone_analyzer.py
 CATEGORIES = frozenset(
     [
+        "all_existing_names",
         "delegations",
         "dnames",
         "ents",
@@ -37,6 +38,7 @@ CATEGORIES = frozenset(
         "reachable_delegations",
         "reachable_dnames",
         "reachable_wildcards",
+        "reachable_wildcard_parents",
         "wildcards",
     ]
 )
@@ -73,6 +75,7 @@ def name2tags(name):
         tags.add("occluded")
 
     if "occluded" not in tags:
+        tags.add("all_existing_names")
         if "delegations" in tags:
             # delegations are ambiguous and don't count as 'reachable'
             tags.add("reachable_delegations")
@@ -110,12 +113,25 @@ def add_ents(nodes):
             except ValueError:
                 break
             entname = Name(name[entidx:])
-            new_ents[entname] = {"ents"}
+            new_ents[entname] = {"all_existing_names", "ents"}
             entidx += 1
 
     return new_ents
 
 
+def tag_wildcard_parents(nodes):
+    """
+    Non-occluded nodes with '*' as a leftmost label tag their immediate parent
+    nodes as 'reachable_wildcard_parents'.
+    """
+    for name, tags in nodes.items():
+        if "occluded" in tags or not name.is_wild():
+            continue
+
+        parent_name = Name(name[1:])
+        nodes[parent_name].add("reachable_wildcard_parents")
+
+
 def is_non_ent(labels):
     """
     Filter out nodes with 'ent' at leftmost position. To become ENT a name must
@@ -180,10 +196,11 @@ def generate_test_data():
         for labelseq in filter(is_non_ent, itertools.product(LABELS, repeat=length)):
             gen_node(nodes, labelseq)
 
-    nodes.update(add_ents(nodes))
-
     # special-case to make this look as a valid DNS zone - it needs zone origin node
-    nodes[Name([])] = {"reachable"}
+    nodes[Name([])] = {"all_existing_names", "reachable"}
+
+    nodes.update(add_ents(nodes))
+    tag_wildcard_parents(nodes)
 
     with open("analyzer.db", "w", encoding="ascii") as outf:
         outf.writelines(gen_zone(nodes))