From 132e68fddbdcdbb1009ffd100721e9c108f191c2 Mon Sep 17 00:00:00 2001 From: Mark Andrews Date: Tue, 22 Apr 2025 18:47:19 +1000 Subject: [PATCH 01/25] Check that correct NSEC3 proofs are returned --- bin/tests/system/dnssec/tests.sh | 45 +++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/bin/tests/system/dnssec/tests.sh b/bin/tests/system/dnssec/tests.sh index 86441c6f94..60a3959ab2 100644 --- a/bin/tests/system/dnssec/tests.sh +++ b/bin/tests/system/dnssec/tests.sh @@ -4720,30 +4720,51 @@ status=$((status + ret)) echo_i "checking NSEC3 nxdomain response closest encloser with 0 ENT ($n)" ret=0 -dig_with_opts @10.53.0.3 b.b.b.b.b.a.nsec3.example. >dig.out.ns3.test$n -grep "status: NXDOMAIN" dig.out.ns3.test$n >/dev/null || ret=1 -pat="^6OVDUHTN094ML2PV8AN90U0DPU823GH2\.nsec3.example\..*NSEC3 1 0 0 - 7AT0S0RIDCJRFF2M5H5AAV22CSFJBUL4 A RRSIG\$" -grep "$pat" dig.out.ns3.test$n >/dev/null || ret=1 +dig_with_opts @10.53.0.4 b.b.b.b.b.a.nsec3.example. >dig.out.ns4.test$n +grep "status: NXDOMAIN" dig.out.ns4.test$n >/dev/null || ret=1 +# closest encloser (a.nsec3.example) +pat1="^6OVDUHTN094ML2PV8AN90U0DPU823GH2\.nsec3\.example\..*NSEC3 1 0 0 - 7AT0S0RIDCJRFF2M5H5AAV22CSFJBUL4 A RRSIG\$" +grep "$pat1" dig.out.ns4.test$n >/dev/null || ret=1 +# no QNAME proof (b.a.nsec3.example / DSPF4R9UKOEPJ9O34E1H4539LSOTL14E) +pat2="^CG2DVCNE20EKU1PDRLMI2L4DGC2FO1H3\.nsec3\.example\..*NSEC3 1 0 0 - EF2S05SGK1IR2K5SKMFIRERGQCLMR18M A RRSIG\$" +grep "$pat2" dig.out.ns4.test$n >/dev/null || ret=1 +# no WILDCARD proof (*.a.nsec3.example / TFGQ60S97BS31IT1EBEDO63ETM0T5JFA) +pat3="^R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\.nsec3\.example\..*NSEC3 1 0 0 - VH656EQUD4J02OFVSO4GKOK5D02MS1TL NS DS RRSIG\$" +grep "$pat3" dig.out.ns4.test$n >/dev/null || ret=1 n=$((n + 1)) if [ "$ret" -ne 0 ]; then echo_i "failed"; fi status=$((status + ret)) echo_i "checking NSEC3 nxdomain response closest encloser with 1 ENTs ($n)" ret=0 -dig_with_opts @10.53.0.3 b.b.b.b.b.a.a.nsec3.example. >dig.out.ns3.test$n -grep "status: NXDOMAIN" dig.out.ns3.test$n >/dev/null || ret=1 -pat="^NGCJFSOLJUUE27PFNQNJIME4TQ0OU2DH\.nsec3.example\..*NSEC3 1 0 0 - R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\$" -grep "$pat" dig.out.ns3.test$n >/dev/null || ret=1 +dig_with_opts @10.53.0.4 b.b.b.b.b.a.a.nsec3.example. >dig.out.ns4.test$n +grep "status: NXDOMAIN" dig.out.ns4.test$n >/dev/null || ret=1 +# closest encloser (a.a.nsec3.example) +pat1="^NGCJFSOLJUUE27PFNQNJIME4TQ0OU2DH\.nsec3\.example\..*NSEC3 1 0 0 - R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\$" +grep "$pat1" dig.out.ns4.test$n >/dev/null || ret=1 +# no QNAME proof (b.a.a.nsec3.example / V8I8SAIIVC3HOVMOVENSDRA6ATDCEMJI) +pat2="^R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\.nsec3\.example\..*NSEC3 1 0 0 - VH656EQUD4J02OFVSO4GKOK5D02MS1TL NS DS RRSIG\$" +grep "$pat2" dig.out.ns4.test$n >/dev/null || ret=1 +# no WILDCARD proof (*.a.a.nsec3.example / V7JNNDJ4NLRIU195FRB7DLUCSLU4LLFM) +pat3="^R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\.nsec3\.example\..*NSEC3 1 0 0 - VH656EQUD4J02OFVSO4GKOK5D02MS1TL NS DS RRSIG\$" +grep "$pat3" dig.out.ns4.test$n >/dev/null || ret=1 n=$((n + 1)) if [ "$ret" -ne 0 ]; then echo_i "failed"; fi status=$((status + ret)) echo_i "checking NSEC3 nxdomain response closest encloser with 2 ENTs ($n)" ret=0 -dig_with_opts @10.53.0.3 b.b.b.b.b.a.a.a.nsec3.example. >dig.out.ns3.test$n -grep "status: NXDOMAIN" dig.out.ns3.test$n >/dev/null || ret=1 -pat="^H7RHPDCHSVVRAND332F878C8AB6IBJQV\.nsec3.example\..*NSEC3 1 0 0 - K8IG76R2UPQ13IKFO49L7IB9JRVB6QJI\$" -grep "$pat" dig.out.ns3.test$n >/dev/null || ret=1 +dig_with_opts @10.53.0.4 b.b.b.b.b.a.a.a.nsec3.example. >dig.out.ns4.test$n +grep "status: NXDOMAIN" dig.out.ns4.test$n >/dev/null || ret=1 +# closest encloser (a.a.a.nsec3.example) +pat1="^H7RHPDCHSVVRAND332F878C8AB6IBJQV\.nsec3\.example\..*NSEC3 1 0 0 - K8IG76R2UPQ13IKFO49L7IB9JRVB6QJI\$" +grep "$pat1" dig.out.ns4.test$n >/dev/null || ret=1 +# no QNAME proof (b.a.a.a.nsec3.example / 18Q8D89RM8GGRSSOPFRB05QS6VEGB1P4) +pat2="^VH656EQUD4J02OFVSO4GKOK5D02MS1TL\.nsec3\.example\..*NSEC3 1 0 0 - 1HARMGSKJH0EBU2EI2OJIKTDPIQA6KBI NS DS RRSIG\$" +grep "$pat2" dig.out.ns4.test$n >/dev/null || ret=1 +# no WILDCARD proof (*.a.a.a.nsec3.example / 8113LDMSEFPUAG4VGFF1C8KLOUT4Q6PH) +pat3="^7AT0S0RIDCJRFF2M5H5AAV22CSFJBUL4\.nsec3\.example\..*NSEC3 1 0 0 - BEJ5GMQA872JF4DAGQ0R3O5Q7A2O5S9L A RRSIG\$" +grep "$pat3" dig.out.ns4.test$n >/dev/null || ret=1 n=$((n + 1)) if [ "$ret" -ne 0 ]; then echo_i "failed"; fi status=$((status + ret)) From 955e3ccf3e3cfb7f622733a6966d292856b062ae Mon Sep 17 00:00:00 2001 From: Matthijs Mekking Date: Tue, 29 Apr 2025 15:01:53 +0200 Subject: [PATCH 02/25] Add a property based test for NSEC3 proofs for non-existent QNAMEs For any given NSEC3 signed zone, when doing queries for non-existent names, the response must contain: - NSEC3 RR that matches the closest encloser, - NSEC3 RR that covers the next closer name, - NSEC3 RR that covers the wildcard. --- bin/tests/system/dnssec/tests_nsec3.py | 255 +++++++++++++++++++++ bin/tests/system/dnssec/tests_sh_dnssec.py | 1 + 2 files changed, 256 insertions(+) create mode 100755 bin/tests/system/dnssec/tests_nsec3.py diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py new file mode 100755 index 0000000000..5c5bd9e2af --- /dev/null +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -0,0 +1,255 @@ +#!/usr/bin/python3 + +# 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. + +import base64 +import os +from pathlib import Path + +import pytest + +pytest.importorskip("dns", minversion="2.0.0") +from dns.dnssectypes import NSEC3Hash +import dns.dnssec +import dns.message +import dns.name +import dns.query +import dns.rcode +import dns.rdataclass +import dns.rdatatype +import dns.rrset + +from hypothesis import assume, given + +from isctest.hypothesis.strategies import dns_names +import isctest + +SUFFIX = dns.name.from_text("nsec3.example.") +AUTH = "10.53.0.3" +RESOLVER = "10.53.0.4" +TIMEOUT = 5 + + +def get_known_names_and_delegations(): + + # Read zone file + system_test_root = Path(os.environ["srcdir"]) + with open( + f"{system_test_root}/dnssec/ns3/nsec3.example.db.in", encoding="utf-8" + ) as zf: + content = dns.zone.from_file(zf, origin=SUFFIX, relativize=False) + all_names = set(content) + known_names = sorted(all_names) + + # Remove out of zone, obscured and glue names + for known_name in known_names: + relation, _, _ = known_name.fullcompare(SUFFIX) + if relation == dns.name.NameRelation.EQUAL: + continue + if relation in (dns.name.NameRelation.NONE, dns.name.NameRelation.SUPERDOMAIN): + known_names.remove(known_name) + continue + nsset = content.get_rdataset(known_name, rdtype=dns.rdatatype.NS) + dname = content.get_rdataset(known_name, rdtype=dns.rdatatype.DNAME) + if nsset is not None or dname is not None: + for glue in known_names: + relation, _, _ = glue.fullcompare(known_name) + if relation == dns.name.NameRelation.SUBDOMAIN: + known_names.remove(glue) + + # Add in possible ENT names + for known_name in known_names: + _, super_name = known_name.split(len(known_name.labels) - 1) + while len(super_name.labels) > len(SUFFIX.labels): + known_names.append(super_name) + _, super_name = super_name.split(len(super_name.labels) - 1) + known_names = set(known_names) + + # Build list of delegation points and DNAMES + delegations = [] + for known_name in known_names: + relation, _, _ = known_name.fullcompare(SUFFIX) + if relation == dns.name.NameRelation.EQUAL: + continue + nsset = content.get_rdataset(known_name, rdtype=dns.rdatatype.NS) + dname = content.get_rdataset(known_name, rdtype=dns.rdatatype.DNAME) + if nsset is not None or dname is not None: + delegations.append(known_name) + + # build list of WILDCARD named + wildcards = [] + for known_name in known_names: + if known_name.is_wild(): + wildcards.append(known_name) + return known_names, delegations, wildcards + + +KNOWN_NAMES, DELEGATIONS, WILDCARDS = get_known_names_and_delegations() + + +def is_delegated(name, delegations): + for delegation in delegations: + relation, _, _ = name.fullcompare(delegation) + if relation in (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN): + return True + return False + + +def get_next_name(rr: dns.rrset.RRset, origin: dns.name.Name) -> dns.name.Name: + """ + Get the domain name of the next NSEC3, given the NSEC3 record 'rr'. + This fetches the value of the Next Hashed Owner Name field, and + creates the domain name by concatenating the decoded hash and the + origin. + """ + # Conversion copied from dnspython.dnssec.nsec3_hash + b32_conversion = str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789ABCDEFGHIJKLMNOPQRSTUV" + ) + next_hashed = base64.b32encode(rr.next).decode("utf-8") + next_hashed = next_hashed.translate(b32_conversion) + next_name = dns.name.from_text(next_hashed, origin) + return next_name + + +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'. + """ + prev_name = rrset.name + + for nsec3 in rrset: + next_name = get_next_name(nsec3, SUFFIX) + + # Single name case. + if prev_name == next_name: + return prev_name != hashed_name + + # Standard case. + if prev_name < next_name: + if prev_name < hashed_name < next_name: + return True + + # The cover wraps. + if next_name < prev_name: + # Case 1: The covered name is at the end of the chain. + if hashed_name > prev_name: + return True + # Case 2: The covered name is at the start of the chain. + if hashed_name < next_name: + return True + return False + + +def check_nsec3_covers(name: dns.name.Name, response: dns.message.Message) -> None: + name_is_covered = False + + nhash = dns.dnssec.nsec3_hash( + name, salt=None, iterations=0, algorithm=NSEC3Hash.SHA1 + ) + hashed_name = dns.name.from_text(nhash, SUFFIX) + + for rrset in response.authority: + if rrset.match(dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE): + name_is_covered = nsec3_covers(rrset, hashed_name) + if name_is_covered: + break + + assert ( + name_is_covered + ), f"Expected covering NSEC3 for {name} (hash={nhash}) not found:\n {response}" + + +@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: + # Name must not exist. + assume(name not in KNOWN_NAMES) + + # Name must not be below a delegation or DNAME. + assume(not is_delegated(name, DELEGATIONS)) + + query = dns.message.make_query( + name, dns.rdatatype.A, 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) + + # 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 + 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 + # 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) + else: + ce_labels = 0 + for zname in KNOWN_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. + 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 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 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) diff --git a/bin/tests/system/dnssec/tests_sh_dnssec.py b/bin/tests/system/dnssec/tests_sh_dnssec.py index e4e0a085f5..c717eaff8f 100644 --- a/bin/tests/system/dnssec/tests_sh_dnssec.py +++ b/bin/tests/system/dnssec/tests_sh_dnssec.py @@ -13,6 +13,7 @@ import pytest pytestmark = pytest.mark.extra_artifacts( [ + ".hypothesis/examples/*", "K*", "canonical*", "delv.out*", From f9e12a840d80add44fc3a8240b93329959f94f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 7 May 2025 16:58:08 +0200 Subject: [PATCH 03/25] Dedup NSEC3 get_next_name function --- bin/tests/system/dnssec/tests_nsec3.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 5c5bd9e2af..3c4efafcfb 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -11,13 +11,12 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -import base64 import os from pathlib import Path import pytest -pytest.importorskip("dns", minversion="2.0.0") +pytest.importorskip("dns", minversion="2.5.0") from dns.dnssectypes import NSEC3Hash import dns.dnssec import dns.message @@ -104,23 +103,6 @@ def is_delegated(name, delegations): return False -def get_next_name(rr: dns.rrset.RRset, origin: dns.name.Name) -> dns.name.Name: - """ - Get the domain name of the next NSEC3, given the NSEC3 record 'rr'. - This fetches the value of the Next Hashed Owner Name field, and - creates the domain name by concatenating the decoded hash and the - origin. - """ - # Conversion copied from dnspython.dnssec.nsec3_hash - b32_conversion = str.maketrans( - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789ABCDEFGHIJKLMNOPQRSTUV" - ) - next_hashed = base64.b32encode(rr.next).decode("utf-8") - next_hashed = next_hashed.translate(b32_conversion) - next_name = dns.name.from_text(next_hashed, origin) - return next_name - - 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'. @@ -128,7 +110,7 @@ def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: prev_name = rrset.name for nsec3 in rrset: - next_name = get_next_name(nsec3, SUFFIX) + next_name = nsec3.next_name(SUFFIX) # Single name case. if prev_name == next_name: From 84ad35e7affd1c17e29721a3d84e283642cf6af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 7 May 2025 15:45:24 +0200 Subject: [PATCH 04/25] Test also with subdomains of existing names Composite strategy makes sure we always test with a subdomain of an existing name. --- bin/tests/system/dnssec/tests_nsec3.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 3c4efafcfb..1a7dbe71ee 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -158,6 +158,27 @@ def check_nsec3_covers(name: dns.name.Name, response: dns.message.Message) -> No # @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) + + +@strategies.composite +def generate_subdomain_of_existing_name(draw): + existing = draw(strategies.sampled_from(sorted(KNOWN_NAMES))) + subdomain = draw(isctest.hypothesis.strategies.dns_names(suffix=existing)) + return subdomain + + +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] +) +@given(name=generate_subdomain_of_existing_name()) +def test_dnssec_nsec3_subdomain_nxdomain( + server, name: dns.name.Name, named_port: int +) -> None: + noqname_test(server, name, named_port) + + +def noqname_test(server, name: dns.name.Name, named_port: int) -> None: # Name must not exist. assume(name not in KNOWN_NAMES) From e263df8848b9998d0578d03ffb48a6235f60aada Mon Sep 17 00:00:00 2001 From: Evan Hunt Date: Tue, 29 Apr 2025 17:03:47 -0700 Subject: [PATCH 05/25] Add property based test for nsec3hash utility Check the correctness of NSEC3 hash generation by generating random combinations of name, salt, and iterations and comparing the outputs of the nsec3hash tool against the dnspython nsec3_hash function for the same inputs. --- .../system/tools/tests_tools_nsec3hash.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/bin/tests/system/tools/tests_tools_nsec3hash.py b/bin/tests/system/tools/tests_tools_nsec3hash.py index 67709f271e..f1384d6ceb 100644 --- a/bin/tests/system/tools/tests_tools_nsec3hash.py +++ b/bin/tests/system/tools/tests_tools_nsec3hash.py @@ -15,6 +15,12 @@ import subprocess import pytest import isctest +from isctest.hypothesis.strategies import dns_names + +from hypothesis import strategies, given, settings + +from dns.dnssectypes import NSEC3Hash +import dns.dnssec NSEC3HASH = os.environ.get("NSEC3HASH") @@ -120,3 +126,51 @@ def test_nsec3_missing_args(args): def test_nsec3_bad_option(): with pytest.raises(subprocess.CalledProcessError): isctest.run.cmd([NSEC3HASH, "-?"]) + + +@given( + domain=dns_names(), + it=strategies.integers(min_value=0, max_value=65535), + salt_bytes=strategies.binary(min_size=0, max_size=255), +) +def test_nsec3hash_acceptable_values(domain, it, salt_bytes) -> None: + if not salt_bytes: + salt_text = "-" + else: + salt_text = salt_bytes.hex() + # calculate the hash using dnspython: + hash1 = dns.dnssec.nsec3_hash( + domain, salt=salt_bytes, iterations=it, algorithm=NSEC3Hash.SHA1 + ) + + # calculate the hash using nsec3hash: + output = isctest.run.cmd( + [NSEC3HASH, salt_text, "1", str(it), str(domain)] + ).stdout.decode("ascii") + hash2 = output.partition(" ")[0] + + assert hash1 == hash2 + + +@settings(max_examples=5) +@given( + domain=dns_names(), + it=strategies.integers(min_value=0, max_value=65535), + salt_bytes=strategies.binary(min_size=256), +) +def test_nsec3hash_salt_too_long(domain, it, salt_bytes) -> None: + salt_text = salt_bytes.hex() + with pytest.raises(subprocess.CalledProcessError): + isctest.run.cmd([NSEC3HASH, salt_text, "1", str(it), str(domain)]) + + +@settings(max_examples=5) +@given( + domain=dns_names(), + it=strategies.integers(min_value=65536), + salt_bytes=strategies.binary(min_size=0, max_size=255), +) +def test_nsec3hash_too_many_iterations(domain, it, salt_bytes) -> None: + salt_text = salt_bytes.hex() + with pytest.raises(subprocess.CalledProcessError): + isctest.run.cmd([NSEC3HASH, salt_text, "1", str(it), str(domain)]) From bd8be10329d1db72a57c8151c0cfc0f42021ee1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 21 May 2025 14:55:45 +0200 Subject: [PATCH 06/25] Move multi-subdomain name generator into shared utilities --- bin/tests/system/dnssec/tests_nsec3.py | 9 +-------- .../system/isctest/hypothesis/strategies.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 1a7dbe71ee..83e63a7059 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -161,17 +161,10 @@ def test_dnssec_nsec3_nxdomain(server, name: dns.name.Name, named_port: int) -> noqname_test(server, name, named_port) -@strategies.composite -def generate_subdomain_of_existing_name(draw): - existing = draw(strategies.sampled_from(sorted(KNOWN_NAMES))) - subdomain = draw(isctest.hypothesis.strategies.dns_names(suffix=existing)) - return subdomain - - @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] ) -@given(name=generate_subdomain_of_existing_name()) +@given(name=dns_names(suffix=KNOWN_NAMES)) def test_dnssec_nsec3_subdomain_nxdomain( server, name: dns.name.Name, named_port: int ) -> None: diff --git a/bin/tests/system/isctest/hypothesis/strategies.py b/bin/tests/system/isctest/hypothesis/strategies.py index 0828496360..a3f9eac2b2 100644 --- a/bin/tests/system/isctest/hypothesis/strategies.py +++ b/bin/tests/system/isctest/hypothesis/strategies.py @@ -11,7 +11,8 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -from typing import List +import collections.abc +from typing import List, Union from warnings import warn from hypothesis.strategies import ( @@ -22,6 +23,7 @@ from hypothesis.strategies import ( just, nothing, permutations, + sampled_from, ) import dns.name @@ -37,7 +39,9 @@ def dns_names( draw, *, prefix: dns.name.Name = dns.name.empty, - suffix: dns.name.Name = dns.name.root, + suffix: Union[ + dns.name.Name, collections.abc.Iterable[dns.name.Name] + ] = dns.name.root, min_labels: int = 1, max_labels: int = 128, ) -> dns.name.Name: @@ -71,6 +75,14 @@ def dns_names( """ prefix = prefix.relativize(dns.name.root) + # Python str is iterable, but that's most probably not what user actually wanted + if isinstance(suffix, str): + raise NotImplementedError( + "ambiguous API use, convert suffix to Name or list to express intent" + ) + if isinstance(suffix, collections.abc.Iterable): + suffix = draw(sampled_from(sorted(suffix))) + assert isinstance(suffix, dns.name.Name) suffix = suffix.derelativize(dns.name.root) try: From 3fb6b990af3ec44e0b0d4e14d76d8c4bfba9692b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 21 May 2025 15:19:25 +0200 Subject: [PATCH 07/25] Shorten syntax to access Name object dns.name all over the place does not make it easier to read the code at all, and I'm going to add lot more code here. --- bin/tests/system/isctest/name.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/tests/system/isctest/name.py b/bin/tests/system/isctest/name.py index d992ae3fce..867380a8f2 100644 --- a/bin/tests/system/isctest/name.py +++ b/bin/tests/system/isctest/name.py @@ -9,12 +9,12 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -import dns.name +from dns.name import Name -def prepend_label(label: str, name: dns.name.Name) -> dns.name.Name: - return dns.name.Name((label,) + name.labels) +def prepend_label(label: str, name: Name) -> Name: + return Name((label,) + name.labels) -def len_wire_uncompressed(name: dns.name.Name) -> int: +def len_wire_uncompressed(name: Name) -> int: return len(name) + sum(map(len, name.labels)) From dbba59f48b8ec0fa12e7519fda206272c2893011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 21 May 2025 16:40:01 +0200 Subject: [PATCH 08/25] Separate zone analyzer from NSEC3 test Code to generate ENTs, detect wildcards, occlusion etc. is generic enough to be in an utility module. --- bin/tests/system/dnssec/tests_nsec3.py | 88 +++------------ bin/tests/system/isctest/__init__.py | 1 - bin/tests/system/isctest/name.py | 150 ++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 72 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 83e63a7059..b06a3772fa 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -31,76 +31,15 @@ from hypothesis import assume, given from isctest.hypothesis.strategies import dns_names import isctest +import isctest.name SUFFIX = dns.name.from_text("nsec3.example.") AUTH = "10.53.0.3" RESOLVER = "10.53.0.4" TIMEOUT = 5 - - -def get_known_names_and_delegations(): - - # Read zone file - system_test_root = Path(os.environ["srcdir"]) - with open( - f"{system_test_root}/dnssec/ns3/nsec3.example.db.in", encoding="utf-8" - ) as zf: - content = dns.zone.from_file(zf, origin=SUFFIX, relativize=False) - all_names = set(content) - known_names = sorted(all_names) - - # Remove out of zone, obscured and glue names - for known_name in known_names: - relation, _, _ = known_name.fullcompare(SUFFIX) - if relation == dns.name.NameRelation.EQUAL: - continue - if relation in (dns.name.NameRelation.NONE, dns.name.NameRelation.SUPERDOMAIN): - known_names.remove(known_name) - continue - nsset = content.get_rdataset(known_name, rdtype=dns.rdatatype.NS) - dname = content.get_rdataset(known_name, rdtype=dns.rdatatype.DNAME) - if nsset is not None or dname is not None: - for glue in known_names: - relation, _, _ = glue.fullcompare(known_name) - if relation == dns.name.NameRelation.SUBDOMAIN: - known_names.remove(glue) - - # Add in possible ENT names - for known_name in known_names: - _, super_name = known_name.split(len(known_name.labels) - 1) - while len(super_name.labels) > len(SUFFIX.labels): - known_names.append(super_name) - _, super_name = super_name.split(len(super_name.labels) - 1) - known_names = set(known_names) - - # Build list of delegation points and DNAMES - delegations = [] - for known_name in known_names: - relation, _, _ = known_name.fullcompare(SUFFIX) - if relation == dns.name.NameRelation.EQUAL: - continue - nsset = content.get_rdataset(known_name, rdtype=dns.rdatatype.NS) - dname = content.get_rdataset(known_name, rdtype=dns.rdatatype.DNAME) - if nsset is not None or dname is not None: - delegations.append(known_name) - - # build list of WILDCARD named - wildcards = [] - for known_name in known_names: - if known_name.is_wild(): - wildcards.append(known_name) - return known_names, delegations, wildcards - - -KNOWN_NAMES, DELEGATIONS, WILDCARDS = get_known_names_and_delegations() - - -def is_delegated(name, delegations): - for delegation in delegations: - relation, _, _ = name.fullcompare(delegation) - if relation in (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN): - return True - return False +ZONE = isctest.name.ZoneAnalyzer.read_path( + Path(os.environ["builddir"]) / "dnssec/ns3/nsec3.example.db.in", origin=SUFFIX +) def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: @@ -164,7 +103,7 @@ def test_dnssec_nsec3_nxdomain(server, name: dns.name.Name, named_port: int) -> @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] ) -@given(name=dns_names(suffix=KNOWN_NAMES)) +@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: @@ -173,10 +112,19 @@ def test_dnssec_nsec3_subdomain_nxdomain( def noqname_test(server, name: dns.name.Name, named_port: int) -> None: # Name must not exist. - assume(name not in KNOWN_NAMES) + all_existing_names = ( + ZONE.reachable.union(ZONE.ents).union(ZONE.delegations).union(ZONE.dnames) + ) + assume(name not in (all_existing_names)) # Name must not be below a delegation or DNAME. - assume(not is_delegated(name, DELEGATIONS)) + assume( + not isctest.name.is_related_to_any( + name, + (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN), + ZONE.reachable_delegations.union(ZONE.reachable_dnames), + ) + ) query = dns.message.make_query( name, dns.rdatatype.A, use_edns=True, want_dnssec=True @@ -208,7 +156,7 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None: _, nce = name.split(ce_labels + 1) else: ce_labels = 0 - for zname in KNOWN_NAMES: + for zname in all_existing_names: relation, _, nlabels = name.fullcompare(zname) if relation == dns.name.NameRelation.SUBDOMAIN: if nlabels > ce_labels: @@ -241,7 +189,7 @@ def noqname_test(server, name: dns.name.Name, named_port: int) -> None: if response.rcode() is dns.rcode.NOERROR: # only NOERRORs should be from wildcards found_wc = False - for wildcard in WILDCARDS: + for wildcard in ZONE.reachable_wildcards: if wildcard == wc: found_wc = True assert found_wc diff --git a/bin/tests/system/isctest/__init__.py b/bin/tests/system/isctest/__init__.py index c3e65d8866..fb04a2a1e8 100644 --- a/bin/tests/system/isctest/__init__.py +++ b/bin/tests/system/isctest/__init__.py @@ -13,7 +13,6 @@ from . import check from . import instance from . import query from . import kasp -from . import name from . import rndc from . import run from . import template diff --git a/bin/tests/system/isctest/name.py b/bin/tests/system/isctest/name.py index 867380a8f2..75c22720f6 100644 --- a/bin/tests/system/isctest/name.py +++ b/bin/tests/system/isctest/name.py @@ -9,7 +9,14 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -from dns.name import Name +from typing import Container, Iterable, FrozenSet + +import pytest + +pytest.importorskip("dns", minversion="2.1.0") # NameRelation +from dns.name import Name, NameRelation +import dns.zone +import dns.rdatatype def prepend_label(label: str, name: Name) -> Name: @@ -18,3 +25,144 @@ def prepend_label(label: str, name: Name) -> Name: def len_wire_uncompressed(name: Name) -> int: return len(name) + sum(map(len, name.labels)) + + +def get_wildcard_names(names: Iterable[Name]) -> FrozenSet[Name]: + return frozenset(name for name in names if name.is_wild()) + + +class ZoneAnalyzer: + """ + Categorize names in zone and provide list of ENTs: + + - delegations - names with NS RR + - dnames - names with DNAME RR + - wildcards - names with leftmost label '*' + - reachable - non-empty authoritative nodes in zone + - have at least one auth RR set and are not occluded + - ents - reachable empty non-terminals + - occluded - names under a parent node which has DNAME or a non-apex NS + - reachable_delegations + - 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 + + Warnings: + - Quadratic complexity ahead! Use only on small test zones. + - Zone must be constant. + """ + + @classmethod + def read_path(cls, zpath, origin): + with open(zpath, encoding="ascii") as zf: + zonedb = dns.zone.from_file(zf, origin, relativize=False) + return cls(zonedb) + + def __init__(self, zone: dns.zone.Zone): + self.zone = zone + assert self.zone.origin # mypy hack + # based on individual nodes but not relationship between nodes + self.delegations = self.get_names_with_type(dns.rdatatype.NS) - { + self.zone.origin + } + self.dnames = self.get_names_with_type(dns.rdatatype.DNAME) + self.wildcards = get_wildcard_names(self.zone) + + # takes relationship between nodes into account + self._categorize_names() + self.ents = self.generate_ents() + self.reachable_dnames = self.dnames.intersection(self.reachable) + self.reachable_wildcards = self.wildcards.intersection(self.reachable) + + def get_names_with_type(self, rdtype) -> FrozenSet[Name]: + return frozenset( + name for name in self.zone if self.zone.get_rdataset(name, rdtype) + ) + + def _categorize_names( + self, + ) -> None: + """ + Split names defined in a zone into three sets: + Generally reachable, reachable delegations, and occluded. + + Delegations are set aside because they are a weird hybrid with different + rules for different RR types (NS, DS, NSEC, everything else). + """ + assert self.zone.origin # mypy workaround + reachable = set(self.zone) + # assume everything is reachable until proven otherwise + reachable_delegations = set(self.delegations) + occluded = set() + + def _mark_occluded(name: Name) -> None: + occluded.add(name) + if name in reachable: + reachable.remove(name) + if name in reachable_delegations: + reachable_delegations.remove(name) + + # sanity check, should be impossible with dnspython 2.7.0 zone reader + for name in reachable: + relation, _, _ = name.fullcompare(self.zone.origin) + if relation in ( + NameRelation.NONE, # out of zone? + NameRelation.SUPERDOMAIN, # parent of apex?! + ): + raise NotImplementedError + + for maybe_occluded in reachable.copy(): + for cut in self.delegations: + rel, _, _ = maybe_occluded.fullcompare(cut) + if rel == NameRelation.EQUAL: + # data _on_ a parent-side of a zone cut are in limbo: + # - are not really authoritative (except for DS) + # - but NS is not really 'occluded' + # We remove them from 'reachable' but do not add them to 'occluded' set, + # i.e. leave them in 'reachable_delegations'. + if maybe_occluded in reachable: + reachable.remove(maybe_occluded) + + if rel == NameRelation.SUBDOMAIN: + _mark_occluded(maybe_occluded) + # do not break cycle - handle also nested NS and DNAME + + # DNAME itself is authoritative but nothing under it is reachable + for dname in self.dnames: + rel, _, _ = maybe_occluded.fullcompare(dname) + if rel == NameRelation.SUBDOMAIN: + _mark_occluded(maybe_occluded) + # do not break cycle - handle also nested NS and DNAME + + self.reachable = frozenset(reachable) + self.reachable_delegations = frozenset(reachable_delegations) + self.occluded = frozenset(occluded) + + def generate_ents(self) -> FrozenSet[Name]: + """ + Generate reachable names of empty nodes "between" all reachable + names with a RR and the origin. + """ + assert self.zone.origin + all_reachable_names = self.reachable.union(self.reachable_delegations) + ents = set() + for name in all_reachable_names: + _, super_name = name.split(len(name) - 1) + while len(super_name) > len(self.zone.origin): + if super_name not in all_reachable_names: + ents.add(super_name) + _, super_name = super_name.split(len(super_name) - 1) + + return frozenset(ents) + + +def is_related_to_any( + test_name: Name, + acceptable_relations: Container[NameRelation], + candidates: Iterable[Name], +) -> bool: + for maybe_parent in candidates: + relation, _, _ = test_name.fullcompare(maybe_parent) + if relation in acceptable_relations: + return True + return False From cad48e56ab5e08ccb7bde55948a511538cef2649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Wed, 21 May 2025 17:58:17 +0200 Subject: [PATCH 09/25] Test ZoneAnalyzer utility class I've considered writing hypothesis test for this but I would have to reimplement the same thing, which would probably have the same logic bugs, so I will leave it as an exercise for someone else. --- bin/tests/system/selftest/analyzer.db | 46 ++++++ .../system/selftest/tests_zone_analyzer.py | 134 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 bin/tests/system/selftest/analyzer.db create mode 100755 bin/tests/system/selftest/tests_zone_analyzer.py diff --git a/bin/tests/system/selftest/analyzer.db b/bin/tests/system/selftest/analyzer.db new file mode 100644 index 0000000000..298de3a7bc --- /dev/null +++ b/bin/tests/system/selftest/analyzer.db @@ -0,0 +1,46 @@ +; 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 ; 5 minutes +@ IN SOA mname1. . ( + 2000042407 ; serial + 20 ; refresh (20 seconds) + 20 ; retry (20 seconds) + 1814400 ; expire (3 weeks) + 3600 ; minimum (1 hour) + ) + NS ns +ns A 10.53.0.3 + +a A 10.0.0.1 +b A 10.0.0.2 +d A 10.0.0.4 +z A 10.0.0.26 +a.a.a.a A 10.0.0.3 +*.wild A 10.0.0.6 +underwild.wild TXT "not occluded" +insecure NS ns.insecure +secondns.insecure NS ns.insecure +insecure NS ns.insecure2 +belowcut.insecure TXT "occluded" +belowcut2.insecure TXT "occluded" ; two RRs, just in case +dname.insecure DNAME occluded. +ns.insecure A 10.53.0.3 +02HC3EM7BDD011A0GMS3HKKJT2IF5VP8 A 10.0.0.17 +dname DNAME elsewhere. +*.dname TXT "occluded" +occluded.dname TXT "occluded" +occluded2.dname TXT "occluded" +nsunder.occluded.dname NS ns +nsunder.dname NS ns +*.nsunder.dname TXT "occluded" + +test. TXT "out of zone" diff --git a/bin/tests/system/selftest/tests_zone_analyzer.py b/bin/tests/system/selftest/tests_zone_analyzer.py new file mode 100755 index 0000000000..1808cd4beb --- /dev/null +++ b/bin/tests/system/selftest/tests_zone_analyzer.py @@ -0,0 +1,134 @@ +#!/usr/bin/python3 + +# 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. + +import os +from pathlib import Path + +import pytest + +pytest.importorskip("dns", minversion="2.5.0") +import dns.name + +import isctest + +from typing import List + + +SUFFIX = dns.name.from_text("nsec3.example.") +ZONE = isctest.name.ZoneAnalyzer.read_path( + Path(os.environ["builddir"]) / "selftest/analyzer.db", origin=SUFFIX +) + + +def text_to_names(texts: List[str]): + return frozenset(dns.name.from_text(text, origin=SUFFIX) for text in texts) + + +def test_analyzer_delegations(): + assert ZONE.delegations == text_to_names( + [ + "nsunder.dname", + "nsunder.occluded.dname", + "insecure", + "secondns.insecure", + ] + ) + + +def test_analyzer_dnames(): + assert ZONE.dnames == text_to_names( + [ + "dname", + "dname.insecure", + ] + ) + + +def test_analyzer_ents(): + assert ZONE.ents == text_to_names( + [ + "a.a", + "a.a.a", + "wild", + ] + ) + + +def test_analyzer_occluded(): + assert ZONE.occluded == text_to_names( + [ + "*.dname", + "nsunder.dname", + "*.nsunder.dname", + "occluded.dname", + "nsunder.occluded.dname", + "occluded2.dname", + "belowcut.insecure", + "belowcut2.insecure", + "dname.insecure", + "ns.insecure", + "secondns.insecure", + ] + ) + + +def test_analyzer_reachable(): + assert ZONE.reachable == text_to_names( + [ + "@", + "02HC3EM7BDD011A0GMS3HKKJT2IF5VP8", + "a", + "a.a.a.a", + "b", + "d", + "dname", + "ns", + "*.wild", + "underwild.wild", + "z", + ] + ) + + +def test_analyzer_reachable_delegations(): + assert ZONE.reachable_delegations == text_to_names( + [ + "insecure", + ] + ) + + +def test_analyzer_reachable_dnames(): + assert ZONE.reachable_dnames == text_to_names( + [ + "dname", + ] + ) + + +def test_analyzer_reachable_wildcards(): + assert ZONE.reachable_wildcards == text_to_names( + [ + "*.wild", + ] + ) + + +def test_analyzer_wildcards(): + assert ZONE.wildcards == text_to_names( + [ + "*.dname", + "*.nsunder.dname", + "*.wild", + ] + ) From 42b60a3819ea48ad38fe9bcf5ff1ccfb752315ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 23 May 2025 09:07:02 +0200 Subject: [PATCH 10/25] Generate comprehensive tests for ZoneAnalyzer utility class Test all combinations of wildcard, ENT, DNAME, NS, and ordinary TXT records. Test zone and expected outputs are generated by another script which encodes node content into node name. This encoding removes 'node content' level of indirection and thus enables simpler implementation of same logic which needs to be in ZoneAnalyzer itself. For humans the generated zone file also lists expected 'categories' a name belongs to as dot-separated list on right hand side of a generated RR. --- bin/tests/system/selftest/analyzer.db | 46 --- .../system/selftest/tests_zone_analyzer.py | 279 +++++++++++------- 2 files changed, 178 insertions(+), 147 deletions(-) delete mode 100644 bin/tests/system/selftest/analyzer.db diff --git a/bin/tests/system/selftest/analyzer.db b/bin/tests/system/selftest/analyzer.db deleted file mode 100644 index 298de3a7bc..0000000000 --- a/bin/tests/system/selftest/analyzer.db +++ /dev/null @@ -1,46 +0,0 @@ -; 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 ; 5 minutes -@ IN SOA mname1. . ( - 2000042407 ; serial - 20 ; refresh (20 seconds) - 20 ; retry (20 seconds) - 1814400 ; expire (3 weeks) - 3600 ; minimum (1 hour) - ) - NS ns -ns A 10.53.0.3 - -a A 10.0.0.1 -b A 10.0.0.2 -d A 10.0.0.4 -z A 10.0.0.26 -a.a.a.a A 10.0.0.3 -*.wild A 10.0.0.6 -underwild.wild TXT "not occluded" -insecure NS ns.insecure -secondns.insecure NS ns.insecure -insecure NS ns.insecure2 -belowcut.insecure TXT "occluded" -belowcut2.insecure TXT "occluded" ; two RRs, just in case -dname.insecure DNAME occluded. -ns.insecure A 10.53.0.3 -02HC3EM7BDD011A0GMS3HKKJT2IF5VP8 A 10.0.0.17 -dname DNAME elsewhere. -*.dname TXT "occluded" -occluded.dname TXT "occluded" -occluded2.dname TXT "occluded" -nsunder.occluded.dname NS ns -nsunder.dname NS ns -*.nsunder.dname TXT "occluded" - -test. TXT "out of zone" diff --git a/bin/tests/system/selftest/tests_zone_analyzer.py b/bin/tests/system/selftest/tests_zone_analyzer.py index 1808cd4beb..9115d3ca64 100755 --- a/bin/tests/system/selftest/tests_zone_analyzer.py +++ b/bin/tests/system/selftest/tests_zone_analyzer.py @@ -1,5 +1,4 @@ #!/usr/bin/python3 - # Copyright (C) Internet Systems Consortium, Inc. ("ISC") # # SPDX-License-Identifier: MPL-2.0 @@ -10,125 +9,203 @@ # # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +""" +isctest.name.ZoneAnalyzer self-test +Generate insane test zone and check expected output of ZoneAnalyzer utility class +""" -import os + +import collections +import itertools from pathlib import Path +import dns.name +from dns.name import Name import pytest -pytest.importorskip("dns", minversion="2.5.0") -import dns.name - import isctest +import isctest.name -from typing import List - - -SUFFIX = dns.name.from_text("nsec3.example.") -ZONE = isctest.name.ZoneAnalyzer.read_path( - Path(os.environ["builddir"]) / "selftest/analyzer.db", origin=SUFFIX +# set of properies present in the tested zone - read by tests_zone_analyzer.py +CATEGORIES = frozenset( + [ + "delegations", + "dnames", + "ents", + "occluded", + "reachable", + "reachable_delegations", + "reachable_dnames", + "reachable_wildcards", + "wildcards", + ] ) -def text_to_names(texts: List[str]): - return frozenset(dns.name.from_text(text, origin=SUFFIX) for text in texts) +pytestmark = pytest.mark.extra_artifacts(["analyzer.db"]) +SUFFIX = dns.name.from_text("nsec3.example.") + +LABELS = (b"*", b"dname", b"ent", b"ns", b"txt") +LABEL2RRTYPE = { # leftmost label encodes RR type we will synthesize for given name + b"*": "TXT", + b"dname": "DNAME", + b"ent": None, # ENT is not really a 'type' + b"ns": "NS", + b"txt": "TXT", +} +LABEL2TAGS = { # leftmost label encodes 'initial' meaning of a complete name + b"*": {"wildcards"}, + b"dname": {"dnames"}, + b"ns": {"delegations"}, + b"txt": set(), # perhaps reachable, perhaps not, we need to decide based on other labels +} -def test_analyzer_delegations(): - assert ZONE.delegations == text_to_names( - [ - "nsunder.dname", - "nsunder.occluded.dname", - "insecure", - "secondns.insecure", - ] +def name2tags(name): + """ + Decode meaning hidden in labels and their relationships + and return all tags expected from ZoneAnalyzer + """ + tags = LABEL2TAGS[name[0]].copy() + + parent_labels = name[1:] + if b"ns" in parent_labels or b"dname" in parent_labels: + tags.add("occluded") + + if "occluded" not in tags: + if "delegations" in tags: + # delegations are ambiguous and don't count as 'reachable' + tags.add("reachable_delegations") + elif "dnames" in tags: + tags.add("reachable") + tags.add("reachable_dnames") + elif "wildcards" in tags: + tags.add("reachable") + tags.add("reachable_wildcards") + else: + tags.add("reachable") + + return tags + + +def gen_node(nodes, labels): + name = Name(labels) + nodes[name] = name2tags(name) + + +def add_ents(nodes): + """ + Non-occluded nodes with 'ent' as a parent label imply existence of 'ent' nodes. + """ + new_ents = {} + for name, tags in nodes.items(): + if "occluded" in tags: + continue + + # check if any parent is ENT + entidx = 1 + while True: + try: + entidx = name.labels.index(b"ent", entidx) + except ValueError: + break + entname = Name(name[entidx:]) + new_ents[entname] = {"ents"} + entidx += 1 + + return new_ents + + +def is_non_ent(labels): + """ + Filter out nodes with 'ent' at leftmost position. To become ENT a name must + not have data by itself but have some other node defined underneath it, + and must not be occluded, which is something itertools.product() cannot + decide. + """ + return labels[0] != b"ent" + + +def gen_zone(nodes): + """ + Generate zone file in text format. + + All names are relative. + Right-hand side of RRs contains dot-separated list of categories a node + belongs to (except for zone origin). + """ + for name, tags in sorted(nodes.items()): + if len(name) == 0: + # origin, very special case + yield "@\tSOA\treachable. origin-special-case. 0 0 0 0 0\n" + yield "@\tNS\treachable.\n" + yield "@\tA\t192.0.2.1\n" + continue + + rrtype = LABEL2RRTYPE[name[0]] + if rrtype is None: # ENT + prefix = "; " + else: + prefix = "" + assert tags + yield f"{prefix}{name}\t{rrtype}\t{'.'.join(sorted(tags))}.\n" + + +def gen_expected_output(nodes): + """ + {category: set(names)} mapping used by the pytest check + """ + categories = collections.defaultdict(set) + for name, tags in nodes.items(): + for tag in tags: + categories[tag].add(name) + + assert set(categories.keys()) == CATEGORIES, ( + "CATEGORIES needs updating", + CATEGORIES.symmetric_difference(set(categories.keys())), ) - -def test_analyzer_dnames(): - assert ZONE.dnames == text_to_names( - [ - "dname", - "dname.insecure", - ] - ) + return categories -def test_analyzer_ents(): - assert ZONE.ents == text_to_names( - [ - "a.a", - "a.a.a", - "wild", - ] - ) +def generate_test_data(): + """ + Prepare the analyzer.db zone file in the current working directory and + return the expected attribute values for the ZoneAnalyzer instance that + will be tested using that file. + """ + nodes = {} + + for length in range(1, len(LABELS) + 1): + 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"} + + with open("analyzer.db", "w", encoding="ascii") as outf: + outf.writelines(gen_zone(nodes)) + + return gen_expected_output(nodes) -def test_analyzer_occluded(): - assert ZONE.occluded == text_to_names( - [ - "*.dname", - "nsunder.dname", - "*.nsunder.dname", - "occluded.dname", - "nsunder.occluded.dname", - "occluded2.dname", - "belowcut.insecure", - "belowcut2.insecure", - "dname.insecure", - "ns.insecure", - "secondns.insecure", - ] - ) +if __name__ == "__main__": + generate_test_data() -def test_analyzer_reachable(): - assert ZONE.reachable == text_to_names( - [ - "@", - "02HC3EM7BDD011A0GMS3HKKJT2IF5VP8", - "a", - "a.a.a.a", - "b", - "d", - "dname", - "ns", - "*.wild", - "underwild.wild", - "z", - ] - ) +@pytest.fixture(scope="module") +def analyzer_fixture(): + expected_results = generate_test_data() # creates the "analyzer.db" file + analyzer = isctest.name.ZoneAnalyzer.read_path(Path("analyzer.db"), origin=SUFFIX) + return expected_results, analyzer -def test_analyzer_reachable_delegations(): - assert ZONE.reachable_delegations == text_to_names( - [ - "insecure", - ] - ) - - -def test_analyzer_reachable_dnames(): - assert ZONE.reachable_dnames == text_to_names( - [ - "dname", - ] - ) - - -def test_analyzer_reachable_wildcards(): - assert ZONE.reachable_wildcards == text_to_names( - [ - "*.wild", - ] - ) - - -def test_analyzer_wildcards(): - assert ZONE.wildcards == text_to_names( - [ - "*.dname", - "*.nsunder.dname", - "*.wild", - ] - ) +# pylint: disable=redefined-outer-name +@pytest.mark.parametrize("category", sorted(CATEGORIES)) +def test_analyzer_attrs(category, analyzer_fixture): + expected_results, analyzer = analyzer_fixture + # relativize results to zone name to make debugging easier + results = {name.relativize(SUFFIX) for name in getattr(analyzer, category)} + assert results == expected_results[category] From 9cea2af25ca90b206c1a8a9255883b15097a9973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Mon, 2 Jun 2025 13:06:54 +0200 Subject: [PATCH 11/25] Use isctest library to check hypothesis version Side-effect of importing from isctest.hypothesis first is a version check and clean Pytest skip if version is too old. --- bin/tests/system/dnssec/tests_nsec3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index b06a3772fa..1f01074edb 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -27,12 +27,12 @@ import dns.rdataclass import dns.rdatatype import dns.rrset -from hypothesis import assume, given - from isctest.hypothesis.strategies import dns_names import isctest import isctest.name +from hypothesis import assume, given + SUFFIX = dns.name.from_text("nsec3.example.") AUTH = "10.53.0.3" RESOLVER = "10.53.0.4" From f0592de608af06792dbc14829a0ac3671b9ed868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Tue, 3 Jun 2025 17:20:54 +0200 Subject: [PATCH 12/25] 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. --- bin/tests/system/dnssec/tests_nsec3.py | 72 +++++++------------ bin/tests/system/isctest/name.py | 36 ++++++++++ .../system/selftest/tests_zone_analyzer.py | 25 +++++-- 3 files changed, 84 insertions(+), 49 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 1f01074edb..f7f98be364 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -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) diff --git a/bin/tests/system/isctest/name.py b/bin/tests/system/isctest/name.py index 75c22720f6..255150e837 100644 --- a/bin/tests/system/isctest/name.py +++ b/bin/tests/system/isctest/name.py @@ -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, diff --git a/bin/tests/system/selftest/tests_zone_analyzer.py b/bin/tests/system/selftest/tests_zone_analyzer.py index 9115d3ca64..9cea8c7986 100755 --- a/bin/tests/system/selftest/tests_zone_analyzer.py +++ b/bin/tests/system/selftest/tests_zone_analyzer.py @@ -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)) From 9ca2077274908d86599e0161cf2c0ccc140b224f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Thu, 5 Jun 2025 15:15:08 +0200 Subject: [PATCH 13/25] 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. --- bin/tests/system/dnssec/tests_nsec3.py | 215 ++++++++++++++++--------- 1 file changed, 143 insertions(+), 72 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index f7f98be364..c7824abf0b 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)) +@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) - # 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), - ) + wname = ZONE.source_of_synthesis(qname) + assume(wname in ZONE.reachable_wildcards) + + 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 ) + nsec3_owner = dns.name.from_text(name_hash, SUFFIX) - query = dns.message.make_query( - name, dns.rdatatype.A, 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) + 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) + wname = ZONE.source_of_synthesis(qname) + # expecting proof that wildcard owner does not have rdatatype requested + check_nsec3_owner(wname, response) - 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) +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) From cfaf5c997f73e1d91735d6c87a2a21cab391eabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 6 Jun 2025 17:10:42 +0200 Subject: [PATCH 14/25] Add consistency checks to responses with NSEC3 Basic sanity checks - limited to responses from a single zone: - NSEC3 type cannot be present in type bitmap: By definition, the type bitmap describes state of the unhashed name but NSEC3 RR is present at a different owner name. RFC 7129 section 5 - NSEC3 owner names cannot be duplicated: Unless the response crosses zone boundary, parent zone has insecure delegation for child, but child is signed ... don't do that. - All parameters are consistent across all RRs present in answer: RFC 5155 section 7.2, last paragraph - at least when we don't cross zone boundary. --- bin/tests/system/dnssec/tests_nsec3.py | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index c7824abf0b..76e0faf599 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -26,6 +26,7 @@ import dns.rcode import dns.rdataclass import dns.rdatatype import dns.rdtypes.ANY.RRSIG +import dns.rdtypes.ANY.NSEC3 import dns.rrset from isctest.hypothesis.strategies import dns_names @@ -48,6 +49,7 @@ def do_test_query(qname, qtype, server, named_port) -> dns.message.Message: 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) + NSEC3Checker(response) return response @@ -250,3 +252,58 @@ def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> N assert nce == qname.split(wildcard_parent_labels + 1)[1] # nce must be proven to NOT exist check_nsec3_covers(nce, response) + + +class NSEC3Checker: + def __init__(self, response: dns.message.Message): + for rrset in response.answer: + assert not rrset.match( + dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE + ), f"unexpected NSEC3 RR in ANSWER section:\n{response}" + for rrset in response.additional: + assert not rrset.match( + dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE + ), f"unexpected NSEC3 RR in ADDITIONAL section:\n{response}" + + attrs_seen = { + "algorithm": None, + "flags": None, + "iterations": None, + "salt": None, + } + first = True + owners_seen = set() + for rrset in response.authority: + if not rrset.match( + dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE + ): + continue + assert ( + rrset.name not in owners_seen + ), f"duplicate NSEC3 owner {rrset.name}:\n{response}" + owners_seen.add(rrset.name) + + assert len(rrset) == 1 + rr = rrset[0] + assert isinstance(rr, dns.rdtypes.ANY.NSEC3.NSEC3) + + assert ( + "NSEC3" + not in dns.rdtypes.ANY.NSEC3.Bitmap(rr.windows).to_text().split() + ), f"NSEC3 RRset with NSEC3 in type bitmap:\n{response}" + + # NSEC3 parameters MUST be consistent across all NSEC3 RRs: + # RFC 5155 section 7.2, last paragraph + for attr_name, value_seen in attrs_seen.items(): + current = getattr(rr, attr_name) + if first: + attrs_seen[attr_name] = current + else: + assert ( + current == value_seen + ), f"inconsistent {attr_name}\n{response}" + first = False + + assert attrs_seen["algorithm"] is not None, f"no NSEC3 found\n{response}" + self.attrs = attrs_seen + self.response = response From c45ad518607d26854329a2535f8b9c5485fb7d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Mon, 9 Jun 2025 10:46:34 +0200 Subject: [PATCH 15/25] Move proof checking into a NSEC3Checker class --- bin/tests/system/dnssec/tests_nsec3.py | 180 +++++++++++++------------ 1 file changed, 95 insertions(+), 85 deletions(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index 76e0faf599..efd6619e2a 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -11,13 +11,14 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +from dataclasses import dataclass import os from pathlib import Path +from typing import Optional, Tuple import pytest pytest.importorskip("dns", minversion="2.5.0") -from dns.dnssectypes import NSEC3Hash import dns.dnssec import dns.message import dns.name @@ -44,13 +45,14 @@ ZONE = isctest.name.ZoneAnalyzer.read_path( ) -def do_test_query(qname, qtype, server, named_port) -> dns.message.Message: +def do_test_query( + qname, qtype, server, named_port +) -> Tuple[dns.message.Message, "NSEC3Checker"]: 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) - NSEC3Checker(response) - return response + return response, NSEC3Checker(response) def assume_nx_and_no_delegation(qname): @@ -67,56 +69,6 @@ def assume_nx_and_no_delegation(qname): ) -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', 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. - if prev_name == next_name: - return prev_name != hashed_name - - # Standard case. - if prev_name < next_name: - if prev_name < hashed_name < next_name: - return True - - # The cover wraps. - if next_name < prev_name: - # Case 1: The covered name is at the end of the chain. - if hashed_name > prev_name: - return True - # Case 2: The covered name is at the start of the chain. - if hashed_name < next_name: - return True - return False - - -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( - name, salt=None, iterations=0, algorithm=NSEC3Hash.SHA1 - ) - hashed_name = dns.name.from_text(nhash, SUFFIX) - - for rrset in response.authority: - if rrset.match(dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE): - name_is_covered = nsec3_covers(rrset, hashed_name) - if name_is_covered: - break - - assert ( - name_is_covered - ), f"Expected covering NSEC3 for {name} (hash={nhash}) not found:\n {response}" - - @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] ) @@ -172,52 +124,34 @@ def test_wildcard_nodata(server, qname: dns.name.Name, named_port: int) -> None: 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 - ) - 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}" - - def check_wildcard_nodata(server, named_port: int, qname: dns.name.Name) -> None: - response = do_test_query(qname, dns.rdatatype.AAAA, server, named_port) + response, nsec3check = 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) + nsec3check.prove_name_exists(ce) + nsec3check.prove_name_does_not_exist(nce) wname = ZONE.source_of_synthesis(qname) # expecting proof that wildcard owner does not have rdatatype requested - check_nsec3_owner(wname, response) + nsec3check.prove_name_exists(wname) def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None: - response = do_test_query(qname, dns.rdatatype.A, server, named_port) + response, nsec3check = 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) + nsec3check.prove_name_exists(ce) + nsec3check.prove_name_does_not_exist(nce) wname = ZONE.source_of_synthesis(qname) - check_nsec3_covers(wname, response) + nsec3check.prove_name_does_not_exist(wname) 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) + response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) assert response.rcode() is dns.rcode.NOERROR answer_sig = response.get_rrset( @@ -250,8 +184,17 @@ def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> N 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) + nsec3check.prove_name_does_not_exist(nce) + + +@dataclass(kw_only=True, frozen=True) +class NSEC3Params: + """Common values from a single DNS response""" + + algorithm: int + flags: int + iterations: int + salt: Optional[bytes] class NSEC3Checker: @@ -273,6 +216,7 @@ class NSEC3Checker: } first = True owners_seen = set() + self.rrsets = [] for rrset in response.authority: if not rrset.match( dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE @@ -303,7 +247,73 @@ class NSEC3Checker: current == value_seen ), f"inconsistent {attr_name}\n{response}" first = False + self.rrsets.append(rrset) assert attrs_seen["algorithm"] is not None, f"no NSEC3 found\n{response}" - self.attrs = attrs_seen - self.response = response + self.params = NSEC3Params(**attrs_seen) # type: NSEC3Params + self.response = response # type: dns.message.Message + + @staticmethod + 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', i.e. the name does not exist. + """ + prev_name = rrset.name + + assert len(rrset) == 1 + nsec3 = rrset[0] + assert isinstance(nsec3, dns.rdtypes.ANY.NSEC3.NSEC3) + assert nsec3.flags == 0, "opt-out not supported by test logic" + next_name = nsec3.next_name(SUFFIX) + + # Single name case. + if prev_name == next_name: + return prev_name != hashed_name + + # Standard case. + if prev_name < next_name: + if prev_name < hashed_name < next_name: + return True + + # The cover wraps. + if next_name < prev_name: + # Case 1: The covered name is at the end of the chain. + if hashed_name > prev_name: + return True + # Case 2: The covered name is at the start of the chain. + if hashed_name < next_name: + return True + return False + + def hash_name(self, name: dns.name.Name) -> dns.name.Name: + nhash = dns.dnssec.nsec3_hash( + name, + salt=self.params.salt, + iterations=self.params.iterations, + algorithm=self.params.algorithm, + ) + return dns.name.from_text(nhash, SUFFIX) + + def prove_name_does_not_exist(self, name: dns.name.Name) -> dns.rrset.RRset: + """Hash of a given name must fall between an NSEC3 owner and 'next' name""" + hashed_name = self.hash_name(name) + for rrset in self.rrsets: + name_is_covered = self.nsec3_covers(rrset, hashed_name) + if name_is_covered: + return rrset + + assert ( + False + ), f"Expected covering NSEC3 for {name} (hash={hashed_name}) not found:\n{self.response}" + + def prove_name_exists(self, owner: dns.name.Name) -> dns.rrset.RRset: + """Check response has NSEC3 RR matching given owner name, i.e. the name exists.""" + nsec3_owner = self.hash_name(owner) + for rrset in self.rrsets: + if rrset.match( + nsec3_owner, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE + ): + return rrset + assert ( + False + ), f"Expected matching NSEC3 for {owner} (hash={nsec3_owner}) not found:\n{self.response}" From b854d5a3f5d60ceb275a4d1813e56bc9f5b5c4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Mon, 9 Jun 2025 11:01:27 +0200 Subject: [PATCH 16/25] Detect extraneous NSEC3 RRs in responses We expect minimal possible answers which prove what they have to according to DNSSEC protocol. --- bin/tests/system/dnssec/tests_nsec3.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index efd6619e2a..ae839059cd 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -135,6 +135,7 @@ def check_wildcard_nodata(server, named_port: int, qname: dns.name.Name) -> None wname = ZONE.source_of_synthesis(qname) # expecting proof that wildcard owner does not have rdatatype requested nsec3check.prove_name_exists(wname) + nsec3check.check_extraneous_rrs() def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None: @@ -147,6 +148,7 @@ def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None: wname = ZONE.source_of_synthesis(qname) nsec3check.prove_name_does_not_exist(wname) + nsec3check.check_extraneous_rrs() def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> None: @@ -185,6 +187,7 @@ def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> N # ce is proven to exist by the RRSIG assert nce == qname.split(wildcard_parent_labels + 1)[1] nsec3check.prove_name_does_not_exist(nce) + nsec3check.check_extraneous_rrs() @dataclass(kw_only=True, frozen=True) @@ -252,6 +255,8 @@ class NSEC3Checker: assert attrs_seen["algorithm"] is not None, f"no NSEC3 found\n{response}" self.params = NSEC3Params(**attrs_seen) # type: NSEC3Params self.response = response # type: dns.message.Message + self.owners_present = owners_seen + self.owners_used = set() @staticmethod def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: @@ -300,6 +305,7 @@ class NSEC3Checker: for rrset in self.rrsets: name_is_covered = self.nsec3_covers(rrset, hashed_name) if name_is_covered: + self.owners_used.add(rrset.name) return rrset assert ( @@ -313,7 +319,14 @@ class NSEC3Checker: if rrset.match( nsec3_owner, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE ): + self.owners_used.add(rrset.name) return rrset assert ( False ), f"Expected matching NSEC3 for {owner} (hash={nsec3_owner}) not found:\n{self.response}" + + def check_extraneous_rrs(self): + """Check that all NSEC3 RRs present in the message were actually needed for proofs""" + assert ( + self.owners_used == self.owners_present + ), f"extraneous NSEC3 RRs detected\n{self.response}" From a92391f60f749f133f80a8ee9e3cee38265f3045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Mon, 9 Jun 2025 11:15:04 +0200 Subject: [PATCH 17/25] Test simple NODATA answers with NSEC3 --- bin/tests/system/dnssec/tests_nsec3.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index ae839059cd..4f462cba6b 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -30,7 +30,7 @@ import dns.rdtypes.ANY.RRSIG import dns.rdtypes.ANY.NSEC3 import dns.rrset -from isctest.hypothesis.strategies import dns_names +from isctest.hypothesis.strategies import dns_names, sampled_from import isctest import isctest.name @@ -55,6 +55,19 @@ def do_test_query( return response, NSEC3Checker(response) +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] +) +@given(qname=sampled_from(sorted(ZONE.reachable))) +def test_nodata(server, qname: dns.name.Name, named_port: int) -> None: + """An existing name, no wildcards, but a query type for RRset which does not exist""" + response, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) + assert response.rcode() is dns.rcode.NOERROR + + nsec3check.prove_name_exists(qname) + nsec3check.check_extraneous_rrs() + + def assume_nx_and_no_delegation(qname): assume(qname not in ZONE.all_existing_names) From ac58b580021902a52291583fecc13a76ee5f2db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Mon, 30 Jun 2025 17:03:50 +0200 Subject: [PATCH 18/25] Separate test into a new directory The test actually needs just two servers - auth and resolver. The rest was not needed and made test setup only slower and harder to debug. --- .../system/nsec3-answer/ns1/named.conf.j2 | 31 +++++++++++++++ bin/tests/system/nsec3-answer/ns1/root.db.in | 39 +++++++++++++++++++ bin/tests/system/nsec3-answer/ns1/sign.sh | 32 +++++++++++++++ .../system/nsec3-answer/ns2/named.conf.j2 | 39 +++++++++++++++++++ bin/tests/system/nsec3-answer/setup.sh | 22 +++++++++++ .../{dnssec => nsec3-answer}/tests_nsec3.py | 20 +++++----- 6 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 bin/tests/system/nsec3-answer/ns1/named.conf.j2 create mode 100644 bin/tests/system/nsec3-answer/ns1/root.db.in create mode 100644 bin/tests/system/nsec3-answer/ns1/sign.sh create mode 100644 bin/tests/system/nsec3-answer/ns2/named.conf.j2 create mode 100644 bin/tests/system/nsec3-answer/setup.sh rename bin/tests/system/{dnssec => nsec3-answer}/tests_nsec3.py (95%) diff --git a/bin/tests/system/nsec3-answer/ns1/named.conf.j2 b/bin/tests/system/nsec3-answer/ns1/named.conf.j2 new file mode 100644 index 0000000000..bf7efcd1a6 --- /dev/null +++ b/bin/tests/system/nsec3-answer/ns1/named.conf.j2 @@ -0,0 +1,31 @@ +/* + * 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. + */ + +// NS1 + +options { + query-source address 10.53.0.1; + notify-source 10.53.0.1; + transfer-source 10.53.0.1; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.1; }; + listen-on-v6 { none; }; + recursion no; + dnssec-validation no; +}; + +zone "." { + type primary; + file "root.db.signed"; +}; diff --git a/bin/tests/system/nsec3-answer/ns1/root.db.in b/bin/tests/system/nsec3-answer/ns1/root.db.in new file mode 100644 index 0000000000..295e28c1d9 --- /dev/null +++ b/bin/tests/system/nsec3-answer/ns1/root.db.in @@ -0,0 +1,39 @@ +; 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 . . ( + 2025063000 ; serial + 600 ; refresh + 600 ; retry + 1200 ; expire + 600 ; minimum + ) +. NS a.root-servers.nil. + +02hc3em7bdd011a0gms3hkkjt2if5vp8. A 10.0.0.0 +a. A 10.0.0.1 +*.a.a. A 10.0.0.6 +a.a.a.a. A 10.0.0.3 +b. A 10.0.0.2 +b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b. A 10.0.0.2 +; cname. CNAME cname.a.a. +d. A 10.0.0.4 +dname-nowhere. DNAME does-not-exist. +insecure. NS a.root-servers.nil. +ns.insecure. A 10.53.0.3 +a.root-servers.nil. A 10.53.0.1 +secure. NS a.root-servers.nil. +secure. DS 11111 13 255 00 +occluded.secure. A 0.0.0.0 +*.wild. A 10.0.0.6 +explicit.wild. A 192.0.2.66 +z. A 10.0.0.26 diff --git a/bin/tests/system/nsec3-answer/ns1/sign.sh b/bin/tests/system/nsec3-answer/ns1/sign.sh new file mode 100644 index 0000000000..c91bbdbd64 --- /dev/null +++ b/bin/tests/system/nsec3-answer/ns1/sign.sh @@ -0,0 +1,32 @@ +#!/bin/sh -e + +# 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. + +# shellcheck source=conf.sh +. ../../conf.sh + +set -e + +zone=. +infile=root.db.in +zonefile=root.db + +echo_i "ns1/sign.sh" + +ksk=$("$KEYGEN" -q -fk -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") +zsk=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") + +cat "$infile" "$ksk.key" "$zsk.key" >"$zonefile" + +"$SIGNER" -3 - -o "$zone" "$zonefile" 2>&1 >"$zonefile.sign.log" + +keyfile_to_initial_ds "$ksk" >managed-keys.conf diff --git a/bin/tests/system/nsec3-answer/ns2/named.conf.j2 b/bin/tests/system/nsec3-answer/ns2/named.conf.j2 new file mode 100644 index 0000000000..06f3268b69 --- /dev/null +++ b/bin/tests/system/nsec3-answer/ns2/named.conf.j2 @@ -0,0 +1,39 @@ +/* + * 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. + */ + +// validating resolver + +options { + query-source address 10.53.0.2; + notify-source 10.53.0.2; + transfer-source 10.53.0.2; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.2; }; + listen-on-v6 { none; }; + recursion yes; + dnssec-validation yes; +}; + +controls { + inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +include "../../_common/rndc.key"; + +zone "." { + type hint; + file "../../_common/root.hint"; +}; + +include "../ns1/managed-keys.conf"; diff --git a/bin/tests/system/nsec3-answer/setup.sh b/bin/tests/system/nsec3-answer/setup.sh new file mode 100644 index 0000000000..4a4db2dd0d --- /dev/null +++ b/bin/tests/system/nsec3-answer/setup.sh @@ -0,0 +1,22 @@ +#!/bin/sh -e + +# 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. + +# shellcheck source=conf.sh +. ../conf.sh + +set -e + +( + cd ns1 + $SHELL sign.sh +) diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py similarity index 95% rename from bin/tests/system/dnssec/tests_nsec3.py rename to bin/tests/system/nsec3-answer/tests_nsec3.py index 4f462cba6b..a5ecf80960 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -36,12 +36,12 @@ import isctest.name from hypothesis import assume, given -SUFFIX = dns.name.from_text("nsec3.example.") -AUTH = "10.53.0.3" -RESOLVER = "10.53.0.4" +SUFFIX = dns.name.from_text(".") +AUTH = "10.53.0.1" +RESOLVER = "10.53.0.2" TIMEOUT = 5 ZONE = isctest.name.ZoneAnalyzer.read_path( - Path(os.environ["builddir"]) / "dnssec/ns3/nsec3.example.db.in", origin=SUFFIX + Path(os.environ["srcdir"]) / "nsec3-answer/ns1/root.db.in", origin=SUFFIX ) @@ -56,7 +56,7 @@ def do_test_query( @pytest.mark.parametrize( - "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=sampled_from(sorted(ZONE.reachable))) def test_nodata(server, qname: dns.name.Name, named_port: int) -> None: @@ -72,7 +72,7 @@ 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 + # it would not work with resolver ns2 assume( not isctest.name.is_related_to_any( qname, @@ -83,7 +83,7 @@ def assume_nx_and_no_delegation(qname): @pytest.mark.parametrize( - "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=SUFFIX)) def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: @@ -96,7 +96,7 @@ def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: @pytest.mark.parametrize( - "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.ents)) def test_ents(server, qname: dns.name.Name, named_port: int) -> None: @@ -112,7 +112,7 @@ def test_ents(server, qname: dns.name.Name, named_port: int) -> None: @pytest.mark.parametrize( - "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents)) def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> None: @@ -125,7 +125,7 @@ def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> No @pytest.mark.parametrize( - "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents)) def test_wildcard_nodata(server, qname: dns.name.Name, named_port: int) -> None: From cc6544b41730234e2c5820c5b40a42db31d66b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Thu, 10 Jul 2025 14:45:35 +0200 Subject: [PATCH 19/25] Move query outside of check_() functions This allows better check() code reuse. --- bin/tests/system/nsec3-answer/tests_nsec3.py | 32 +++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/bin/tests/system/nsec3-answer/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py index a5ecf80960..ac3fe44715 100755 --- a/bin/tests/system/nsec3-answer/tests_nsec3.py +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -92,7 +92,8 @@ def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: wname = ZONE.source_of_synthesis(qname) assume(wname not in ZONE.reachable_wildcards) - check_nxdomain(server, named_port, qname) + _, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + check_nxdomain(qname, nsec3check) @pytest.mark.parametrize( @@ -103,12 +104,14 @@ 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) + _, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + 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) + check_wildcard_synthesis(qname, nsec3check) else: - check_nxdomain(server, named_port, qname) + check_nxdomain(qname, nsec3check) @pytest.mark.parametrize( @@ -121,7 +124,8 @@ def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> No wname = ZONE.source_of_synthesis(qname) assume(wname in ZONE.reachable_wildcards) - check_wildcard_synthesis(server, named_port, qname) + _, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + check_wildcard_synthesis(qname, nsec3check) @pytest.mark.parametrize( @@ -134,12 +138,12 @@ def test_wildcard_nodata(server, qname: dns.name.Name, named_port: int) -> None: wname = ZONE.source_of_synthesis(qname) assume(wname in ZONE.reachable_wildcards) - check_wildcard_nodata(server, named_port, qname) + _, nsec3check = do_test_query(qname, dns.rdatatype.AAAA, server, named_port) + check_wildcard_nodata(qname, nsec3check) -def check_wildcard_nodata(server, named_port: int, qname: dns.name.Name) -> None: - response, nsec3check = do_test_query(qname, dns.rdatatype.AAAA, server, named_port) - assert response.rcode() is dns.rcode.NOERROR +def check_wildcard_nodata(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None: + assert nsec3check.response.rcode() is dns.rcode.NOERROR ce, nce = ZONE.closest_encloser(qname) nsec3check.prove_name_exists(ce) @@ -151,9 +155,8 @@ def check_wildcard_nodata(server, named_port: int, qname: dns.name.Name) -> None nsec3check.check_extraneous_rrs() -def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None: - response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) - assert response.rcode() is dns.rcode.NXDOMAIN +def check_nxdomain(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None: + assert nsec3check.response.rcode() is dns.rcode.NXDOMAIN ce, nce = ZONE.closest_encloser(qname) nsec3check.prove_name_exists(ce) @@ -164,12 +167,11 @@ def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None: nsec3check.check_extraneous_rrs() -def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> None: +def check_wildcard_synthesis(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None: """Expect wildcard response with a signed A RRset""" - response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) - assert response.rcode() is dns.rcode.NOERROR + assert nsec3check.response.rcode() is dns.rcode.NOERROR - answer_sig = response.get_rrset( + answer_sig = nsec3check.response.get_rrset( section="ANSWER", name=qname, rdclass=dns.rdataclass.IN, From d0e413dd5763fbe81ba37abeec650f26a9248feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Thu, 10 Jul 2025 13:13:02 +0200 Subject: [PATCH 20/25] Test dangling CNAMEs come with NXDOMAIN proofs Simplistic test. Ignores the possibility of CNAME chain going through multiple zones and/or wildcard expansions. --- bin/tests/system/nsec3-answer/ns1/root.db.in | 4 +++- bin/tests/system/nsec3-answer/tests_nsec3.py | 24 ++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/bin/tests/system/nsec3-answer/ns1/root.db.in b/bin/tests/system/nsec3-answer/ns1/root.db.in index 295e28c1d9..2171c44239 100644 --- a/bin/tests/system/nsec3-answer/ns1/root.db.in +++ b/bin/tests/system/nsec3-answer/ns1/root.db.in @@ -25,7 +25,9 @@ a. A 10.0.0.1 a.a.a.a. A 10.0.0.3 b. A 10.0.0.2 b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b. A 10.0.0.2 -; cname. CNAME cname.a.a. +cname. CNAME does-not-exist. +cname.cname. CNAME cname. +cname.ent.cname. CNAME cname.cname. d. A 10.0.0.4 dname-nowhere. DNAME does-not-exist. insecure. NS a.root-servers.nil. diff --git a/bin/tests/system/nsec3-answer/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py index ac3fe44715..f1b0a70154 100755 --- a/bin/tests/system/nsec3-answer/tests_nsec3.py +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -47,7 +47,7 @@ ZONE = isctest.name.ZoneAnalyzer.read_path( def do_test_query( qname, qtype, server, named_port -) -> Tuple[dns.message.Message, "NSEC3Checker"]: +) -> Tuple[dns.message.QueryMessage, "NSEC3Checker"]: 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) @@ -58,7 +58,11 @@ def do_test_query( @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) -@given(qname=sampled_from(sorted(ZONE.reachable))) +@given( + qname=sampled_from( + sorted(ZONE.reachable - ZONE.get_names_with_type(dns.rdatatype.CNAME)) + ) +) def test_nodata(server, qname: dns.name.Name, named_port: int) -> None: """An existing name, no wildcards, but a query type for RRset which does not exist""" response, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) @@ -96,6 +100,22 @@ def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: check_nxdomain(qname, nsec3check) +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] +) +@given(qname=sampled_from(sorted(ZONE.get_names_with_type(dns.rdatatype.CNAME)))) +def test_cname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: + """CNAME which terminates by NXDOMAIN, no wildcards involved""" + response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + chain = response.resolve_chaining() + assume_nx_and_no_delegation(chain.canonical_name) + + wname = ZONE.source_of_synthesis(chain.canonical_name) + assume(wname not in ZONE.reachable_wildcards) + + check_nxdomain(chain.canonical_name, nsec3check) + + @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) From 73e4201331fb468664aa72faa785acabe97fc820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Thu, 10 Jul 2025 15:14:06 +0200 Subject: [PATCH 21/25] Test dangling DNAME answers come with NXDOMAIN proofs Simplistic test. Ignores the possibility of DNAME chain going through multiple zones and/or wildcard expansions. --- bin/tests/system/nsec3-answer/ns1/root.db.in | 3 ++- bin/tests/system/nsec3-answer/tests_nsec3.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/bin/tests/system/nsec3-answer/ns1/root.db.in b/bin/tests/system/nsec3-answer/ns1/root.db.in index 2171c44239..fbddc2dca2 100644 --- a/bin/tests/system/nsec3-answer/ns1/root.db.in +++ b/bin/tests/system/nsec3-answer/ns1/root.db.in @@ -29,7 +29,8 @@ cname. CNAME does-not-exist. cname.cname. CNAME cname. cname.ent.cname. CNAME cname.cname. d. A 10.0.0.4 -dname-nowhere. DNAME does-not-exist. +dname-to-nowhere. DNAME does-not-exist. +; DNAME owner longer than target to avoid YXDOMAIN dependent on QNAME insecure. NS a.root-servers.nil. ns.insecure. A 10.53.0.3 a.root-servers.nil. A 10.53.0.1 diff --git a/bin/tests/system/nsec3-answer/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py index f1b0a70154..578b5ddc05 100755 --- a/bin/tests/system/nsec3-answer/tests_nsec3.py +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -116,6 +116,24 @@ def test_cname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: check_nxdomain(chain.canonical_name, nsec3check) +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] +) +@given(qname=dns_names(suffix=ZONE.get_names_with_type(dns.rdatatype.DNAME))) +def test_dname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: + """DNAME which terminates by NXDOMAIN, no wildcards involved""" + assume(qname not in ZONE.reachable) + + response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + chain = response.resolve_chaining() + assume_nx_and_no_delegation(chain.canonical_name) + + wname = ZONE.source_of_synthesis(chain.canonical_name) + assume(wname not in ZONE.reachable_wildcards) + + check_nxdomain(chain.canonical_name, nsec3check) + + @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) From 548632b18aee8fa05c67a0284522a1e19183310c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 11 Jul 2025 10:22:33 +0200 Subject: [PATCH 22/25] Test proof of nonexistance of DS in insecure referrals Currently this test is limited only to auth because currently BIND resolver does not send DS proof of nonexistence for RD=0 queries. --- bin/tests/system/nsec3-answer/tests_nsec3.py | 31 ++++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/bin/tests/system/nsec3-answer/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py index 578b5ddc05..fad7595bbd 100755 --- a/bin/tests/system/nsec3-answer/tests_nsec3.py +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -65,10 +65,35 @@ def do_test_query( ) def test_nodata(server, qname: dns.name.Name, named_port: int) -> None: """An existing name, no wildcards, but a query type for RRset which does not exist""" - response, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) - assert response.rcode() is dns.rcode.NOERROR + _, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) + check_nodata(qname, nsec3check) - nsec3check.prove_name_exists(qname) + +@pytest.mark.parametrize("server", [pytest.param(AUTH, id="ns1")]) +@given( + qname=dns_names( + suffix=(ZONE.delegations - ZONE.get_names_with_type(dns.rdatatype.DS)) + ) +) +def test_nodata_ds(server, qname: dns.name.Name, named_port: int) -> None: + """Auth sends proof of nonexistance with referral without DS RR. Opt-out is not supported.""" + response, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) + + nsrr = None + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.NS: + nsrr = rrset + break + assert nsrr is not None, "NS RRset missing in delegation answer" + + # DS RR does not exist so we must prove it by having NSEC3 with QNAME + check_nodata(nsrr.name, nsec3check) + + +def check_nodata(name: dns.name.Name, nsec3check: "NSEC3Checker"): + assert nsec3check.response.rcode() is dns.rcode.NOERROR + + nsec3check.prove_name_exists(name) nsec3check.check_extraneous_rrs() From 46781845ea96f5e1e6052141b1ac844c5483a8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 11 Jul 2025 11:17:05 +0200 Subject: [PATCH 23/25] Randomize NSEC3 salt This should prevent the case where are are unlucky enough that static values hash 'just right' for the test to pass, but only accidentally. --- bin/tests/system/nsec3-answer/ns1/sign.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/tests/system/nsec3-answer/ns1/sign.sh b/bin/tests/system/nsec3-answer/ns1/sign.sh index c91bbdbd64..78e33119f6 100644 --- a/bin/tests/system/nsec3-answer/ns1/sign.sh +++ b/bin/tests/system/nsec3-answer/ns1/sign.sh @@ -27,6 +27,8 @@ zsk=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone") cat "$infile" "$ksk.key" "$zsk.key" >"$zonefile" -"$SIGNER" -3 - -o "$zone" "$zonefile" 2>&1 >"$zonefile.sign.log" +SALT="$(printf "%04x" "$(($(date +%s) / 3600 % 65536))")" +echo_ic "NSEC3 salt for this hour: $SALT" +"$SIGNER" -3 "$SALT" -o "$zone" "$zonefile" 2>&1 >"$zonefile.sign.log" keyfile_to_initial_ds "$ksk" >managed-keys.conf From fc3d5e5918dde168290d97ad122d0948bdc61db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pa=C4=8Dek?= Date: Fri, 11 Jul 2025 13:33:46 +0200 Subject: [PATCH 24/25] Add more empty non-terminals to test zone I don't know exactly why, I just have a feeling there might be interesting corner cases somewhere. --- bin/tests/system/nsec3-answer/ns1/root.db.in | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bin/tests/system/nsec3-answer/ns1/root.db.in b/bin/tests/system/nsec3-answer/ns1/root.db.in index fbddc2dca2..d1f150b385 100644 --- a/bin/tests/system/nsec3-answer/ns1/root.db.in +++ b/bin/tests/system/nsec3-answer/ns1/root.db.in @@ -40,3 +40,12 @@ occluded.secure. A 0.0.0.0 *.wild. A 10.0.0.6 explicit.wild. A 192.0.2.66 z. A 10.0.0.26 + +; randomly generated subtree to excercise unknown corner cases +; intentionally small, to not blow up algorithms with quadratic complexity in ZoneAnalyzer and name generator +a.a.a.b.a.a.a.b.a.a.b.b.a.random. TXT "r" +b.b.a.a.b.b.a.a.a.b.b.a.b.a.a.a.a.a.b.a.a.b.a.b.a.b.b.b.b.b.a.a.a.a.b.a.a.a.b.a.a.b.b.a.random. TXT "r" +a.a.a.b.b.a.b.b.a.b.a.b.a.b.a.b.b.b.a.random. TXT "r" +b.b.a.b.a.b.a.a.a.b.a.a.b.a.a.a.a.b.b.a.b.b.a.b.a.b.a.b.a.b.b.b.a.random. TXT "r" +a.b.a.a.b.a.b.a.b.a.a.b.a.b.a.a.a.b.b.a.b.b.a.a.b.b.a.a.b.a.b.a.b.b.b.b.a.a.a.a.a.a.a.a.b.a.b.a.b.b.a.b.a.b.a.a.a.b.a.a.b.a.a.a.a.b.b.a.b.b.a.b.a.b.a.b.a.b.b.b.a.random. TXT "r" +a.a.a.a.a.b.b.a.a.a.a.a.b.b.a.a.b.a.a.b.a.a.b.b.a.a.a.b.a.a.a.b.b.b.b.b.a.a.a.b.b.b.b.b.b.a.b.b.b.a.a.b.b.b.b.a.a.a.a.b.a.b.b.a.b.a.a.b.b.b.b.b.b.b.a.b.b.a.b.a.b.a.a.a.b.b.a.a.b.b.a.b.a.b.b.a.b.b.b.a.b.b.b.b.b.a.a.b.a.a.a.b.b.a.a.a.b.b.b.b.b.a.random. TXT "r" From adb931f70005f900437dc121097db54cd608de94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99pie=C5=84?= Date: Fri, 25 Jul 2025 14:50:49 +0200 Subject: [PATCH 25/25] Adjust type hints for the "nsec3-answer" test Add missing type hints in the tests_nsec3.py module. Tweak the syntax used for type hints for better consistency with other Python code in bin/tests/system/. --- bin/tests/system/nsec3-answer/tests_nsec3.py | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/bin/tests/system/nsec3-answer/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py index fad7595bbd..8396e9bb5e 100755 --- a/bin/tests/system/nsec3-answer/tests_nsec3.py +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -14,7 +14,7 @@ from dataclasses import dataclass import os from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Set, Tuple import pytest @@ -46,7 +46,7 @@ ZONE = isctest.name.ZoneAnalyzer.read_path( def do_test_query( - qname, qtype, server, named_port + qname: dns.name.Name, qtype: dns.rdatatype.RdataType, server: str, named_port: int ) -> Tuple[dns.message.QueryMessage, "NSEC3Checker"]: query = dns.message.make_query(qname, qtype, use_edns=True, want_dnssec=True) response = isctest.query.tcp(query, server, named_port, timeout=TIMEOUT) @@ -63,7 +63,7 @@ def do_test_query( sorted(ZONE.reachable - ZONE.get_names_with_type(dns.rdatatype.CNAME)) ) ) -def test_nodata(server, qname: dns.name.Name, named_port: int) -> None: +def test_nodata(server: str, qname: dns.name.Name, named_port: int) -> None: """An existing name, no wildcards, but a query type for RRset which does not exist""" _, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) check_nodata(qname, nsec3check) @@ -75,7 +75,7 @@ def test_nodata(server, qname: dns.name.Name, named_port: int) -> None: suffix=(ZONE.delegations - ZONE.get_names_with_type(dns.rdatatype.DS)) ) ) -def test_nodata_ds(server, qname: dns.name.Name, named_port: int) -> None: +def test_nodata_ds(server: str, qname: dns.name.Name, named_port: int) -> None: """Auth sends proof of nonexistance with referral without DS RR. Opt-out is not supported.""" response, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port) @@ -90,14 +90,14 @@ def test_nodata_ds(server, qname: dns.name.Name, named_port: int) -> None: check_nodata(nsrr.name, nsec3check) -def check_nodata(name: dns.name.Name, nsec3check: "NSEC3Checker"): +def check_nodata(name: dns.name.Name, nsec3check: "NSEC3Checker") -> None: assert nsec3check.response.rcode() is dns.rcode.NOERROR nsec3check.prove_name_exists(name) nsec3check.check_extraneous_rrs() -def assume_nx_and_no_delegation(qname): +def assume_nx_and_no_delegation(qname: dns.name.Name) -> None: assume(qname not in ZONE.all_existing_names) # name must not be under a delegation or DNAME: @@ -115,7 +115,7 @@ def assume_nx_and_no_delegation(qname): "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=SUFFIX)) -def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: +def test_nxdomain(server: str, 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) @@ -129,7 +129,7 @@ def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=sampled_from(sorted(ZONE.get_names_with_type(dns.rdatatype.CNAME)))) -def test_cname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: +def test_cname_nxdomain(server: str, qname: dns.name.Name, named_port: int) -> None: """CNAME which terminates by NXDOMAIN, no wildcards involved""" response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) chain = response.resolve_chaining() @@ -145,7 +145,7 @@ def test_cname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.get_names_with_type(dns.rdatatype.DNAME))) -def test_dname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: +def test_dname_nxdomain(server: str, qname: dns.name.Name, named_port: int) -> None: """DNAME which terminates by NXDOMAIN, no wildcards involved""" assume(qname not in ZONE.reachable) @@ -163,7 +163,7 @@ def test_dname_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.ents)) -def test_ents(server, qname: dns.name.Name, named_port: int) -> None: +def test_ents(server: str, qname: dns.name.Name, named_port: int) -> None: """ENT can have a wildcard under it""" assume_nx_and_no_delegation(qname) @@ -181,7 +181,7 @@ def test_ents(server, qname: dns.name.Name, named_port: int) -> None: "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents)) -def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> None: +def test_wildcard_synthesis(server: str, qname: dns.name.Name, named_port: int) -> None: assume(qname not in ZONE.all_existing_names) wname = ZONE.source_of_synthesis(qname) @@ -195,7 +195,7 @@ def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> No "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] ) @given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents)) -def test_wildcard_nodata(server, qname: dns.name.Name, named_port: int) -> None: +def test_wildcard_nodata(server: str, qname: dns.name.Name, named_port: int) -> None: assume(qname not in ZONE.all_existing_names) wname = ZONE.source_of_synthesis(qname) @@ -331,10 +331,10 @@ class NSEC3Checker: self.rrsets.append(rrset) assert attrs_seen["algorithm"] is not None, f"no NSEC3 found\n{response}" - self.params = NSEC3Params(**attrs_seen) # type: NSEC3Params - self.response = response # type: dns.message.Message - self.owners_present = owners_seen - self.owners_used = set() + self.params: NSEC3Params = NSEC3Params(**attrs_seen) + self.response: dns.message.Message = response + self.owners_present: Set[dns.name.Name] = owners_seen + self.owners_used: Set[dns.name.Name] = set() @staticmethod def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: @@ -403,7 +403,7 @@ class NSEC3Checker: False ), f"Expected matching NSEC3 for {owner} (hash={nsec3_owner}) not found:\n{self.response}" - def check_extraneous_rrs(self): + def check_extraneous_rrs(self) -> None: """Check that all NSEC3 RRs present in the message were actually needed for proofs""" assert ( self.owners_used == self.owners_present