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)) 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*", 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/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: diff --git a/bin/tests/system/isctest/name.py b/bin/tests/system/isctest/name.py index d992ae3fce..255150e837 100644 --- a/bin/tests/system/isctest/name.py +++ b/bin/tests/system/isctest/name.py @@ -9,12 +9,196 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -import dns.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: 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)) + + +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 + - reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped + + 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) + 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( + 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 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, + 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 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..d1f150b385 --- /dev/null +++ b/bin/tests/system/nsec3-answer/ns1/root.db.in @@ -0,0 +1,51 @@ +; 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 does-not-exist. +cname.cname. CNAME cname. +cname.ent.cname. CNAME cname.cname. +d. A 10.0.0.4 +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 +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 + +; 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" 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..78e33119f6 --- /dev/null +++ b/bin/tests/system/nsec3-answer/ns1/sign.sh @@ -0,0 +1,34 @@ +#!/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" + +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 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/nsec3-answer/tests_nsec3.py b/bin/tests/system/nsec3-answer/tests_nsec3.py new file mode 100755 index 0000000000..8396e9bb5e --- /dev/null +++ b/bin/tests/system/nsec3-answer/tests_nsec3.py @@ -0,0 +1,410 @@ +#!/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. + +from dataclasses import dataclass +import os +from pathlib import Path +from typing import Optional, Set, Tuple + +import pytest + +pytest.importorskip("dns", minversion="2.5.0") +import dns.dnssec +import dns.message +import dns.name +import dns.query +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, sampled_from +import isctest +import isctest.name + +from hypothesis import assume, given + +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["srcdir"]) / "nsec3-answer/ns1/root.db.in", origin=SUFFIX +) + + +def do_test_query( + 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) + isctest.check.is_response_to(response, query) + assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN) + return response, NSEC3Checker(response) + + +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] +) +@given( + qname=sampled_from( + sorted(ZONE.reachable - ZONE.get_names_with_type(dns.rdatatype.CNAME)) + ) +) +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) + + +@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: 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) + + 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") -> 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: dns.name.Name) -> None: + assume(qname not in ZONE.all_existing_names) + + # name must not be under a delegation or DNAME: + # it would not work with resolver ns2 + assume( + not isctest.name.is_related_to_any( + qname, + (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN), + ZONE.reachable_delegations.union(ZONE.reachable_dnames), + ) + ) + + +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")] +) +@given(qname=dns_names(suffix=SUFFIX)) +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) + assume(wname not in ZONE.reachable_wildcards) + + _, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + 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: 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() + 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")] +) +@given(qname=dns_names(suffix=ZONE.get_names_with_type(dns.rdatatype.DNAME))) +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) + + 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")] +) +@given(qname=dns_names(suffix=ZONE.ents)) +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) + + _, 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(qname, nsec3check) + else: + check_nxdomain(qname, nsec3check) + + +@pytest.mark.parametrize( + "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: str, 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) + + _, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port) + check_wildcard_synthesis(qname, nsec3check) + + +@pytest.mark.parametrize( + "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: str, 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) + + _, nsec3check = do_test_query(qname, dns.rdatatype.AAAA, server, named_port) + check_wildcard_nodata(qname, nsec3check) + + +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) + nsec3check.prove_name_does_not_exist(nce) + + 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(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) + nsec3check.prove_name_does_not_exist(nce) + + wname = ZONE.source_of_synthesis(qname) + nsec3check.prove_name_does_not_exist(wname) + nsec3check.check_extraneous_rrs() + + +def check_wildcard_synthesis(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None: + """Expect wildcard response with a signed A RRset""" + assert nsec3check.response.rcode() is dns.rcode.NOERROR + + answer_sig = nsec3check.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] + nsec3check.prove_name_does_not_exist(nce) + nsec3check.check_extraneous_rrs() + + +@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: + 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() + self.rrsets = [] + 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 + self.rrsets.append(rrset) + + assert attrs_seen["algorithm"] is not None, f"no NSEC3 found\n{response}" + 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: + """ + 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: + self.owners_used.add(rrset.name) + 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 + ): + 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) -> None: + """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}" 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..9cea8c7986 --- /dev/null +++ b/bin/tests/system/selftest/tests_zone_analyzer.py @@ -0,0 +1,228 @@ +#!/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. +""" +isctest.name.ZoneAnalyzer self-test +Generate insane test zone and check expected output of ZoneAnalyzer utility class +""" + + +import collections +import itertools +from pathlib import Path + +import dns.name +from dns.name import Name +import pytest + +import isctest +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", + "occluded", + "reachable", + "reachable_delegations", + "reachable_dnames", + "reachable_wildcards", + "reachable_wildcard_parents", + "wildcards", + ] +) + + +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 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: + tags.add("all_existing_names") + 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] = {"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 + 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())), + ) + + return categories + + +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) + + # special-case to make this look as a valid DNS zone - it needs zone origin node + 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)) + + return gen_expected_output(nodes) + + +if __name__ == "__main__": + generate_test_data() + + +@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 + + +# 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] 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)])