Extract closest encloser and source of synthesis logic into ZoneAnalyzer

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.

(cherry picked from commit f0592de608)
This commit is contained in:
Petr Špaček 2025-06-03 17:20:54 +02:00
parent 2cf035b87d
commit da51bfed8c
3 changed files with 84 additions and 49 deletions

View file

@ -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)

View 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,

View file

@ -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))