From 7853dbac43b79cec75beccc4359b018b1d1e81d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Fri, 3 Apr 2026 12:18:43 +0200 Subject: [PATCH 1/4] Create zone setup helpers in isctest.zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System tests that set up zones — especially DNSSEC tests — require a chain of common operations: rendering zone files from templates, generating keys, signing, and propagating DS records to parent zones. Implement these as methods on isctest.zone.Zone so individual tests don't need to repeat the logic in shell or ad-hoc Python. isctest.zone.Zone is a plain class that holds the zone's data and accumulated state (delegations, keys) alongside the methods that operate on it. It is intentionally separate from isctest.template.Zone, which remains a dumb data container for jinja2 template rendering. Key design points: - zone.Zone.name is the text form without trailing dot ("." for root); zone.Zone.dname holds the dns.name.Name for DNS-level operations; zone.Zone.basename is the filesystem-safe name ("root" for "."). - filepath_unsigned / filepath_signed are both always available. filepath returns the appropriate one based on zone.Zone.signed. - The zones/ subdirectory is the default (subdir="zones"); old-style tests that place zone files directly in the ns workdir can pass subdir=None. - Signing is opt-in via signed=True; configure() auto-detects whether to generate keys and sign based on this flag, so the same method handles both signed and unsigned zones. - delegations and keys are mutable list attributes; callers append to them before calling configure() rather than threading them through every call. Also: - Add isctest.template.zones() as a bridge from a list of zone.Zone to a {name: template.Zone} dict suitable for use as the ``zones`` template variable. template.zones() resolves filepath to the actual zone file so templates don't need to know whether a zone is signed. Assisted-by: Claude:claude-opus-4-8 --- bin/tests/system/conftest.py | 2 + bin/tests/system/isctest/__init__.py | 2 + bin/tests/system/isctest/template.py | 19 ++- bin/tests/system/isctest/zone.py | 174 +++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 bin/tests/system/isctest/zone.py diff --git a/bin/tests/system/conftest.py b/bin/tests/system/conftest.py index 678327a31f..4e58f36c04 100644 --- a/bin/tests/system/conftest.py +++ b/bin/tests/system/conftest.py @@ -318,10 +318,12 @@ def logger(request, system_test_name): def expected_artifacts(request): common_artifacts = [ ".libs/*", # possible build artifacts, see GL #5055 + "ns*/keys", "ns*/named*.conf", "ns*/named.memstats", "ns*/named.run", "ns*/named.run.prev", + "ns*/zones", "core.[0-9]*-backtrace.txt", "core.[0-9]*.gz", "pytest.log.txt", diff --git a/bin/tests/system/isctest/__init__.py b/bin/tests/system/isctest/__init__.py index a817eaa4cd..30256087b3 100644 --- a/bin/tests/system/isctest/__init__.py +++ b/bin/tests/system/isctest/__init__.py @@ -20,6 +20,7 @@ from . import ( # pylint: disable=redefined-builtin template, transfer, vars, + zone, ) # isctest.mark module is intentionally NOT imported, because it relies on @@ -38,4 +39,5 @@ __all__ = [ "template", "transfer", "vars", + "zone", ] diff --git a/bin/tests/system/isctest/template.py b/bin/tests/system/isctest/template.py index 83e6ce8691..0198af3d3d 100644 --- a/bin/tests/system/isctest/template.py +++ b/bin/tests/system/isctest/template.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from pathlib import Path from re import compile as Re -from typing import Any +from typing import TYPE_CHECKING, Any import re @@ -23,6 +23,9 @@ import jinja2 from .log import debug from .vars import ALL +if TYPE_CHECKING: + from .zone import Zone as _SetupZone + NS_DIR_RE = Re(r"^(a?ns([0-9]+))/") @@ -163,3 +166,17 @@ class TrustAnchor: domain: str type: str contents: str + + +def zones(zone_list: "list[_SetupZone]") -> dict[str, Zone]: + """ + Convert a list of zone.Zone instances to a {name: Zone} dict for templates. + + The returned dict maps zone names to plain template Zone instances, suitable + for use as the ``zones`` variable in jinja2 templates. The ``filepath`` of + each template zone is set to the actual zone file (signed or unsigned). + """ + return { + z.name: Zone(name=z.name, ns=z.ns, type=z.type, filepath=z.filepath) + for z in zone_list + } diff --git a/bin/tests/system/isctest/zone.py b/bin/tests/system/isctest/zone.py new file mode 100644 index 0000000000..8db84f23f8 --- /dev/null +++ b/bin/tests/system/isctest/zone.py @@ -0,0 +1,174 @@ +# 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 pathlib import Path + +import shutil + +import dns.name + +from .kasp import Key +from .log import debug +from .run import EnvCmd +from .template import NS1, Nameserver, TemplateEngine, TrustAnchor +from .vars.algorithms import Algorithm + +KEYDIR = "keys" + + +class Zone: + """ + Zone providing zone file setup and signing operations. + + This is the operational counterpart to isctest.template.Zone, which is a + plain data container for template rendering. Use isctest.template.zones() + to convert a list of Zone instances into template data. + + The normal entrypoint is configure(), which runs the full setup once. + The individual steps (copy_dssets, add_keys, render, sign) are public so + that tests needing finer-grained control can drive them directly, but they + are order-dependent and write to the same on-disk locations as configure(). + Pick one or the other for a given Zone: calling a step method and + configure() on the same Zone leaves the zone directory in an inconsistent + state. + """ + + def __init__( + self, + name: str | dns.name.Name, + ns: Nameserver, + signed: bool = False, + subdir: str | None = "zones", + filepath_unsigned: Path | str | None = None, + filepath_signed: Path | str | None = None, + zone_type: str = "primary", + ) -> None: + self.dname: dns.name.Name = ( + dns.name.from_text(name) if isinstance(name, str) else name + ) + raw = self.dname.to_text() + self.name: str = raw if raw == "." else raw.rstrip(".") + self.basename: str = "root" if self.dname == dns.name.root else self.name + self.ns = ns + self.signed = signed + self.subdir = subdir + self.type = zone_type + self._configured = False + + prefix = f"{subdir}/" if subdir else "" + self.filepath_unsigned: Path = Path( + filepath_unsigned or f"{prefix}{self.basename}.db" + ) + self.filepath_signed: Path = Path( + filepath_signed or f"{prefix}{self.basename}.db.signed" + ) + + self.delegations: list[Zone] = [] + self.keys: list[Key] = [] + + @property + def filepath(self) -> Path: + """Actual zone file — filepath_signed if signed, filepath_unsigned otherwise.""" + return self.filepath_signed if self.signed else self.filepath_unsigned + + def make_key(self, params: str = "", alg: Algorithm | None = None) -> Key: + """Generate a DNSSEC key and return it without adding it to self.keys.""" + debug(f"{self.name}: generating key") + keydir = Path(self.ns.name) / KEYDIR + keydir.mkdir(exist_ok=True) + if alg is None: + alg = Algorithm.default() + keygen = EnvCmd( + "KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L 3600" + ) + key_name = keygen(f"{params} {self.name}", cwd=self.ns.name).out.strip() + return Key(key_name, keydir=keydir) + + def add_keys(self, ksk=True, zsk=True) -> None: + """Generate KSK and/or ZSK and append both to self.keys.""" + if ksk: + self.keys.append(self.make_key("-f KSK")) + if zsk: + self.keys.append(self.make_key()) + + def copy_dssets(self) -> None: + """Copy dsset-* files from each delegation's ns dir into self.ns dir.""" + for zone in self.delegations: + try: + shutil.copy(f"{zone.ns.name}/dsset-{zone.name}.", self.ns.name) + except FileNotFoundError: + debug(f"{zone.name}: delegation is insecure (no dsset found)") + else: + debug(f"{zone.name}: delegation is secure") + + def render(self, template: str | None = None) -> None: + """Render the unsigned zone file from a jinja2 template.""" + debug(f"{self.name}: rendering zone file") + templates = TemplateEngine(".") + output = Path(self.ns.name) / self.filepath_unsigned + output.parent.mkdir(exist_ok=True) + + if template is None: + local = f"{output}.j2.manual" + common = "_common/zones/template.db.j2.manual" + template = local if Path(local).is_file() else common + + data = { + "zone": self, + "delegations": self.delegations, + } + templates.render(str(output), data, template=template) + + def sign(self, params: str = "") -> None: + """Sign the rendered zone file. Requires self.signed == True.""" + assert self.signed, f"{self.name}: zone is not configured for signing" + debug(f"{self.name}: signing zone") + signer = EnvCmd("SIGNER", f"-S -g -K {KEYDIR} {params}") + signer( + f"-P -x -O full -o {self.name}" + f" -f {self.filepath_signed} {self.filepath_unsigned}", + cwd=self.ns.name, + ) + + def configure(self, template: str | None = None, sign_params: str = "") -> None: + """ + Perform full zone setup: copy DS sets, generate keys, render, sign. + + This is the standard single-call entrypoint and may be called only once + per Zone. Use the individual step methods directly only when a test + needs finer-grained control, and do not mix them with configure() on the + same Zone (see the class docstring). + """ + assert not self._configured, f"{self.name}: configure() already called" + self._configured = True + self.copy_dssets() + if self.signed: + self.add_keys() + self.render(template) + if self.signed: + self.sign(sign_params) + + def trust_anchor( + self, type: str = "static-ds" # pylint: disable=redefined-builtin + ) -> TrustAnchor: + assert self.keys, "no zone keys configured" + return self.keys[0].into_ta(type) + + +def configure_root( + delegations: list[Zone], + ns: Nameserver = NS1, + signed: bool = True, +) -> Zone: + zone = Zone(".", ns, signed=signed) + zone.delegations = delegations + zone.configure(template="_common/zones/root.db.j2.manual") + return zone From 7ff0321ffac6344e87216903163c5703d5a21dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Fri, 3 Apr 2026 10:39:48 +0000 Subject: [PATCH 2/4] Add dnssec_py system test directory Add a new system test directory for DNSSEC tests written in Python, using the isctest.zone helpers for zone setup rather than shell sign scripts. Set up four nameservers: - ns1: authoritative for the signed root zone - ns2: authoritative for test zones (primary) - ns3: authoritative for additional test zones (typically delegations) - ns9: validating resolver Zone configuration for ns2 and ns3 is driven by the ``zones`` template variable via _common/zones.conf.j2, so each test module's bootstrap() controls which zones those servers load without touching named.conf. Individual test modules will be added in subsequent commits. Assisted-by: Claude:claude-opus-4-8 --- bin/tests/system/dnssec_py/common.py | 19 +++++++++++ bin/tests/system/dnssec_py/ns1/named.conf.j2 | 24 ++++++++++++++ bin/tests/system/dnssec_py/ns2/named.conf.j2 | 34 ++++++++++++++++++++ bin/tests/system/dnssec_py/ns3/named.conf.j2 | 25 ++++++++++++++ bin/tests/system/dnssec_py/ns9/named.conf.j2 | 25 ++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 bin/tests/system/dnssec_py/common.py create mode 100644 bin/tests/system/dnssec_py/ns1/named.conf.j2 create mode 100644 bin/tests/system/dnssec_py/ns2/named.conf.j2 create mode 100644 bin/tests/system/dnssec_py/ns3/named.conf.j2 create mode 100644 bin/tests/system/dnssec_py/ns9/named.conf.j2 diff --git a/bin/tests/system/dnssec_py/common.py b/bin/tests/system/dnssec_py/common.py new file mode 100644 index 0000000000..56a30f0c41 --- /dev/null +++ b/bin/tests/system/dnssec_py/common.py @@ -0,0 +1,19 @@ +# 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 pytest + +DNSSEC_PY_MARK = pytest.mark.extra_artifacts( + [ + "ns*/dsset-*", + "ns*/trusted.conf", + ] +) diff --git a/bin/tests/system/dnssec_py/ns1/named.conf.j2 b/bin/tests/system/dnssec_py/ns1/named.conf.j2 new file mode 100644 index 0000000000..e33e2f5bdb --- /dev/null +++ b/bin/tests/system/dnssec_py/ns1/named.conf.j2 @@ -0,0 +1,24 @@ +// NS1 + +options { + query-source address @ns.ip@; + notify-source @ns.ip@; + transfer-source @ns.ip@; + port @PORT@; + pid-file "named.pid"; + listen-on { @ns.ip@; }; + listen-on-v6 { none; }; + + key-directory "keys"; + + minimal-any no; + minimal-responses no; + recursion no; + + dnssec-validation yes; +}; + +{% include "_common/controls.conf.j2" %} +{% include "_common/trusted.conf.j2" %} + +{% include "_common/zones.conf.j2" %} diff --git a/bin/tests/system/dnssec_py/ns2/named.conf.j2 b/bin/tests/system/dnssec_py/ns2/named.conf.j2 new file mode 100644 index 0000000000..ccc1b3a304 --- /dev/null +++ b/bin/tests/system/dnssec_py/ns2/named.conf.j2 @@ -0,0 +1,34 @@ +// NS2 + +options { + query-source address @ns.ip@; + notify-source @ns.ip@; + transfer-source @ns.ip@; + port @PORT@; + pid-file "named.pid"; + listen-on { @ns.ip@; }; + listen-on-v6 { none; }; + + key-directory "keys"; + + minimal-any no; + minimal-responses no; + recursion no; + + dnssec-validation yes; + +{% if rrset_order_none is defined %} + rrset-order { + /* Keep the RRset order static in responses for zones that depend on it. */ +{% for name in rrset_order_none %} + name "@name@." order none; +{% endfor %} + }; +{% endif %} +}; + +{% include "_common/controls.conf.j2" %} +{% include "_common/trusted.conf.j2" %} +{% include "_common/root.hint.conf" %} + +{% include "_common/zones.conf.j2" %} diff --git a/bin/tests/system/dnssec_py/ns3/named.conf.j2 b/bin/tests/system/dnssec_py/ns3/named.conf.j2 new file mode 100644 index 0000000000..df796cfd12 --- /dev/null +++ b/bin/tests/system/dnssec_py/ns3/named.conf.j2 @@ -0,0 +1,25 @@ +// NS3 + +options { + query-source address @ns.ip@; + notify-source @ns.ip@; + transfer-source @ns.ip@; + port @PORT@; + pid-file "named.pid"; + listen-on { @ns.ip@; }; + listen-on-v6 { none; }; + + key-directory "keys"; + + minimal-any no; + minimal-responses no; + recursion no; + + dnssec-validation yes; +}; + +{% include "_common/controls.conf.j2" %} +{% include "_common/trusted.conf.j2" %} +{% include "_common/root.hint.conf" %} + +{% include "_common/zones.conf.j2" %} diff --git a/bin/tests/system/dnssec_py/ns9/named.conf.j2 b/bin/tests/system/dnssec_py/ns9/named.conf.j2 new file mode 100644 index 0000000000..9e011bf6e5 --- /dev/null +++ b/bin/tests/system/dnssec_py/ns9/named.conf.j2 @@ -0,0 +1,25 @@ +// NS 9 + +{% set max_validations_per_fetch = max_validations_per_fetch | default(16) %} + +options { + query-source address @ns.ip@; + notify-source @ns.ip@; + transfer-source @ns.ip@; + port @PORT@; + pid-file "named.pid"; + listen-on { @ns.ip@; }; + listen-on-v6 { none; }; + + recursion yes; + + dnssec-validation yes; + + max-validations-per-fetch @max_validations_per_fetch@; +}; + +{% include "_common/controls.conf.j2" %} +{% include "_common/trusted.conf.j2" %} +{% include "_common/root.hint.conf" %} + +{% include "_common/zones.conf.j2" %} From fc5116ed9164c28a1c76c2a42f0f99834f47fc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Fri, 3 Apr 2026 13:22:33 +0000 Subject: [PATCH 3/4] Add NSEC3 excessive iterations test to dnssec_py Rewrite nsec3_delegation/tests_excessive_nsec3_iterations.py as dnssec_py/tests_nsec3_iter_too_many.py using the isctest.zone helpers. The test is a reproducer for CVE-2026-1519 [GL#5708]. It sets up a delegation from nsec3-iter-too-many. (ns2) to an unsigned sub zone (ns3), signing the parent with NSEC3 at 51 iterations. A validating resolver (ns9) must use NSEC3 to prove the sub zone is insecure; the excessive iteration count is logged as a warning. The test verifies that the query still resolves successfully (insecure, not SERVFAIL) despite the high iteration count. Assisted-by: Claude:claude-opus-4-8 --- .../dnssec_py/tests_nsec3_iter_too_many.py | 52 ++++++++++++++++ .../system/nsec3_delegation/ns1/named.conf.j2 | 22 ------- bin/tests/system/nsec3_delegation/ns1/root.db | 14 ----- .../ns2/iter-too-many.db.j2.manual | 20 ------ .../system/nsec3_delegation/ns2/named.conf.j2 | 27 -------- .../nsec3_delegation/ns2/sub.iter-too-many.db | 13 ---- .../system/nsec3_delegation/ns3/named.conf.j2 | 24 -------- .../nsec3_delegation/ns3/trusted.conf.j2 | 1 - .../tests_excessive_nsec3_iterations.py | 61 ------------------- 9 files changed, 52 insertions(+), 182 deletions(-) create mode 100644 bin/tests/system/dnssec_py/tests_nsec3_iter_too_many.py delete mode 100644 bin/tests/system/nsec3_delegation/ns1/named.conf.j2 delete mode 100644 bin/tests/system/nsec3_delegation/ns1/root.db delete mode 100644 bin/tests/system/nsec3_delegation/ns2/iter-too-many.db.j2.manual delete mode 100644 bin/tests/system/nsec3_delegation/ns2/named.conf.j2 delete mode 100644 bin/tests/system/nsec3_delegation/ns2/sub.iter-too-many.db delete mode 100644 bin/tests/system/nsec3_delegation/ns3/named.conf.j2 delete mode 120000 bin/tests/system/nsec3_delegation/ns3/trusted.conf.j2 delete mode 100644 bin/tests/system/nsec3_delegation/tests_excessive_nsec3_iterations.py diff --git a/bin/tests/system/dnssec_py/tests_nsec3_iter_too_many.py b/bin/tests/system/dnssec_py/tests_nsec3_iter_too_many.py new file mode 100644 index 0000000000..51d655d700 --- /dev/null +++ b/bin/tests/system/dnssec_py/tests_nsec3_iter_too_many.py @@ -0,0 +1,52 @@ +# 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 re import compile as Re + +from dnssec_py.common import DNSSEC_PY_MARK +from isctest.template import NS2, NS3, zones +from isctest.zone import Zone, configure_root + +import isctest + +pytestmark = DNSSEC_PY_MARK + + +def bootstrap(): + sub = Zone("sub.nsec3-iter-too-many", NS3, signed=False) + sub.configure() + + parent = Zone("nsec3-iter-too-many", NS2, signed=True) + parent.delegations = [sub] + parent.configure(sign_params="-3 A1B2C3D4 -H too-many -H 51") + + root = configure_root([parent]) + + return { + "trust_anchors": root.trust_anchors(), + "zones": zones([root, parent, sub]), + } + + +def test_excessive_nsec3_iterations_delegation(ns9): + # reproducer for CVE-2026-1519 [GL#5708] + zone = "a.sub.nsec3-iter-too-many" + msg = isctest.query.create(zone, "A") + res = isctest.query.tcp(msg, ns9.ip) + + # an insecure response is expected regardless of the NSEC3 iteration limit, + # because the sub.nsec3-iter-too-many. zone is unsigned. the real + # difference is in the CPU usage required for generating such response, but + # that can't be easily and reliably tested in an automated fashion + isctest.check.noerror(res) + + with ns9.watch_log_from_start() as watcher: + watcher.wait_for_line(Re(f"validating {zone}/A:.*too many iterations")) diff --git a/bin/tests/system/nsec3_delegation/ns1/named.conf.j2 b/bin/tests/system/nsec3_delegation/ns1/named.conf.j2 deleted file mode 100644 index 2671a4d2b5..0000000000 --- a/bin/tests/system/nsec3_delegation/ns1/named.conf.j2 +++ /dev/null @@ -1,22 +0,0 @@ -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; -}; - -controls { - inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; -}; - -include "../../_common/rndc.key"; - -zone "." { - type primary; - file "root.db"; -}; diff --git a/bin/tests/system/nsec3_delegation/ns1/root.db b/bin/tests/system/nsec3_delegation/ns1/root.db deleted file mode 100644 index 493fbbed0f..0000000000 --- a/bin/tests/system/nsec3_delegation/ns1/root.db +++ /dev/null @@ -1,14 +0,0 @@ -$TTL 300 -. IN SOA . . ( - 2025063000 ; serial - 600 ; refresh - 600 ; retry - 1200 ; expire - 600 ; minimum - ) -. NS a.root-servers.nil. - -a.root-servers.nil A 10.53.0.1 - -iter-too-many. NS ns2.iter-too-many. -ns2.iter-too-many. A 10.53.0.2 diff --git a/bin/tests/system/nsec3_delegation/ns2/iter-too-many.db.j2.manual b/bin/tests/system/nsec3_delegation/ns2/iter-too-many.db.j2.manual deleted file mode 100644 index 60c90b8c02..0000000000 --- a/bin/tests/system/nsec3_delegation/ns2/iter-too-many.db.j2.manual +++ /dev/null @@ -1,20 +0,0 @@ -{% raw %} -$TTL 300 -@ IN SOA ns2.iter-too-many. hostmaster.iter-too-many. ( - 2026020300 ; serial - 20 ; refresh (20 seconds) - 20 ; retry (20 seconds) - 1814400 ; expire (3 weeks) - 3600 ; minimum (1 hour) -) - -@ IN NS ns2.iter-too-many. -ns2 IN A 10.53.0.2 - -sub IN NS ns2.sub.iter-too-many. -ns2.sub IN A 10.53.0.2 -{% endraw %} - -{% for dnskey in dnskeys %} -@dnskey@ -{% endfor %} diff --git a/bin/tests/system/nsec3_delegation/ns2/named.conf.j2 b/bin/tests/system/nsec3_delegation/ns2/named.conf.j2 deleted file mode 100644 index 1e530103cb..0000000000 --- a/bin/tests/system/nsec3_delegation/ns2/named.conf.j2 +++ /dev/null @@ -1,27 +0,0 @@ -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 no; - dnssec-validation no; -}; - -controls { - inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; -}; - -include "../../_common/rndc.key"; - -zone "iter-too-many" { - type primary; - file "iter-too-many.signed.db"; -}; - -zone "sub.iter-too-many" { - type primary; - file "sub.iter-too-many.db"; -}; diff --git a/bin/tests/system/nsec3_delegation/ns2/sub.iter-too-many.db b/bin/tests/system/nsec3_delegation/ns2/sub.iter-too-many.db deleted file mode 100644 index e19e7c4338..0000000000 --- a/bin/tests/system/nsec3_delegation/ns2/sub.iter-too-many.db +++ /dev/null @@ -1,13 +0,0 @@ -$TTL 300 -@ IN SOA ns2.sub.iter-too-many. hostmaster.sub.iter-too-many. ( - 2026020300 ; serial - 20 ; refresh (20 seconds) - 20 ; retry (20 seconds) - 1814400 ; expire (3 weeks) - 3600 ; minimum (1 hour) -) - -@ IN NS ns2.sub.iter-too-many. -ns2 IN A 10.53.0.2 - -example IN A 127.0.0.1 diff --git a/bin/tests/system/nsec3_delegation/ns3/named.conf.j2 b/bin/tests/system/nsec3_delegation/ns3/named.conf.j2 deleted file mode 100644 index c38345765e..0000000000 --- a/bin/tests/system/nsec3_delegation/ns3/named.conf.j2 +++ /dev/null @@ -1,24 +0,0 @@ -options { - query-source address 10.53.0.3; - notify-source 10.53.0.3; - transfer-source 10.53.0.3; - port @PORT@; - pid-file "named.pid"; - listen-on { 10.53.0.3; }; - listen-on-v6 { none; }; - recursion yes; - dnssec-validation yes; -}; - -controls { - inet 10.53.0.3 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; -}; - -include "../../_common/rndc.key"; - -zone "." { - type hint; - file "../../_common/root.hint"; -}; - -include "trusted.conf"; diff --git a/bin/tests/system/nsec3_delegation/ns3/trusted.conf.j2 b/bin/tests/system/nsec3_delegation/ns3/trusted.conf.j2 deleted file mode 120000 index cb0be77b22..0000000000 --- a/bin/tests/system/nsec3_delegation/ns3/trusted.conf.j2 +++ /dev/null @@ -1 +0,0 @@ -../../_common/trusted.conf.j2 \ No newline at end of file diff --git a/bin/tests/system/nsec3_delegation/tests_excessive_nsec3_iterations.py b/bin/tests/system/nsec3_delegation/tests_excessive_nsec3_iterations.py deleted file mode 100644 index 5bd17ed874..0000000000 --- a/bin/tests/system/nsec3_delegation/tests_excessive_nsec3_iterations.py +++ /dev/null @@ -1,61 +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. - -from isctest.run import EnvCmd - -import isctest - - -def bootstrap(): - templates = isctest.template.TemplateEngine(".") - keygen = EnvCmd("KEYGEN", "-a ECDSA256") - signer = EnvCmd("SIGNER") - - isctest.log.info("setup iter-too-many.") - zonename = "iter-too-many." - ksk_name = keygen(f"-f KSK {zonename}", cwd="ns2").out.strip() - zsk_name = keygen(f"{zonename}", cwd="ns2").out.strip() - ksk = isctest.kasp.Key(ksk_name, keydir="ns2") - zsk = isctest.kasp.Key(zsk_name, keydir="ns2") - dnskeys = [ksk.dnskey, zsk.dnskey] - - tdata = { - "dnskeys": dnskeys, - } - templates.render(f"ns2/{zonename}db", tdata, template=f"ns2/{zonename}db.j2.manual") - signer( - f"-P -o {zonename} -f {zonename}signed.db -3 A1B2C3D4 -H too-many -H 51 -S {zonename}db", - cwd="ns2", - ) - - return { - "trust_anchors": [ - ksk.into_ta("static-key"), - ], - } - - -def test_excessive_nsec3_iterations_delegation(ns3): - # reproducer for CVE-2026-1519 [GL#5708] - zone = "example.sub.iter-too-many" - msg = isctest.query.create(zone, "A") - res = isctest.query.tcp(msg, ns3.ip) - - # an insecure response is expected regardless of the NSEC3 iteration limit, - # because the sub.iter-too-many. zone is unsigned. the real difference is - # in the CPU usage required for generating such response, but that can't be - # easily and reliably tested in an automated fashion - isctest.check.noerror(res) - - with ns3.watch_log_from_start() as watcher: - watcher.wait_for_line( - f"validating {zone}/A: validator_callback_ds: too many iterations" - ) From e11e2c903207cb2ebbad521a308d03efd1ee41ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Wed, 15 Apr 2026 17:03:52 +0000 Subject: [PATCH 4/4] Add ZoneKey helpers for key operations in isctest.zone Introduce an abstract ZoneKey base class with two concrete implementations: - FileZoneKey wraps a dnssec-keygen-managed key file (kasp.Key). - PythonZoneKey holds a Python-native keypair for dnspython-based signing and key operations. Both share ZoneKey.into_ta() and ZoneKey.is_ksk(). The ZoneKey abstraction lets Zone.copy_dssets() and Zone.trust_anchors() handle pure-Python keys without callers needing to know how the key was made. Assisted-by: Claude:claude-opus-4-8 --- bin/tests/system/isctest/zone.py | 327 ++++++++++++++++++++++++++++--- 1 file changed, 297 insertions(+), 30 deletions(-) diff --git a/bin/tests/system/isctest/zone.py b/bin/tests/system/isctest/zone.py index 8db84f23f8..df65932541 100644 --- a/bin/tests/system/isctest/zone.py +++ b/bin/tests/system/isctest/zone.py @@ -9,11 +9,24 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable from pathlib import Path +from typing import TypeAlias import shutil +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa +from dns.rdtypes.dnskeybase import Flag + +import dns.dnssec import dns.name +import dns.rdataclass +import dns.rdatatype +import dns.rrset from .kasp import Key from .log import debug @@ -22,6 +35,253 @@ from .template import NS1, Nameserver, TemplateEngine, TrustAnchor from .vars.algorithms import Algorithm KEYDIR = "keys" +DNSKEY_TTL = 3600 + +PrivateKey: TypeAlias = ( + ec.EllipticCurvePrivateKey + | ed25519.Ed25519PrivateKey + | ed448.Ed448PrivateKey + | rsa.RSAPrivateKey +) + + +class ZoneKey(ABC): + """ + Abstract base for a DNSSEC key attached to a Zone. + + Two concrete implementations exist: + FileZoneKey — wraps a dnssec-keygen-managed key file pair (kasp.Key). + PythonZoneKey — holds a Python-native (private_key, dnskey_rdata) pair + required for dnspython-based operations and signing. + + The interface covers the zone-infrastructure subset: trust anchor + generation and DS propagation to parent zones. + """ + + @property + @abstractmethod + def dnskey(self) -> dns.rrset.RRset: + """The DNSKEY RRset for this key (single-record, with TTL).""" + + def is_ksk(self) -> bool: + return bool(self.dnskey[0].flags & int(Flag.SEP)) + + @abstractmethod + def write_dsset( + self, + target_dir: Path | str, + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> None: + """ + Write or copy dsset-{zone}. into target_dir. + + For FileZoneKey: copies the dsset file produced by dnssec-signzone. + For PythonZoneKey: derives the DS from the in-memory key and writes it. + """ + + def into_ta( + self, + ta_type: str = "static-ds", + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> TrustAnchor: + """ + Build a named.conf trust-anchor stanza from this key. + + ta_type must be one of: static-ds, initial-ds, static-key, initial-key. + Implemented once here; both subclasses inherit it via self.dnskey. + """ + dnskey = self.dnskey + if ta_type in ["static-ds", "initial-ds"]: + ds = dns.dnssec.make_ds(dnskey.name, dnskey[0], dsdigest) + parts = str(ds).split() + contents = " ".join(parts[:3]) + f' "{parts[3]}"' + elif ta_type in ["static-key", "initial-key"]: + parts = str(dnskey).split() + contents = " ".join(parts[4:7]) + f' "{"".join(parts[7:])}"' + else: + raise ValueError(f"invalid trust anchor type: {ta_type!r}") + return TrustAnchor(str(dnskey.name), ta_type, contents) + + +class FileZoneKey(ZoneKey): + """ + A ZoneKey backed by dnssec-keygen-managed key files. + + Constructed by FileZoneKey.generate(); callers normally do not + instantiate this directly. The underlying kasp.Key is accessible via + .key for working with timing metadata, state files, etc. + """ + + def __init__(self, key: Key, zone: Zone) -> None: + self.key = key + self.zone = zone + + @property + def dnskey(self) -> dns.rrset.RRset: + return self.key.dnskey + + def write_dsset( + self, + target_dir: Path | str, + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> None: + """ + Copy the dnssec-signzone-produced dsset-{zone}. into target_dir. + + dsdigest is accepted for interface compatibility but ignored: the dsset + file is produced by dnssec-signzone (SHA-256). This copy overwrites any + existing dsset file, so a zone must not mix FileZoneKey and + PythonZoneKey KSKs (PythonZoneKey appends to the same file); + Zone.copy_dssets enforces this. + """ + src = Path(self.zone.ns.name) / f"dsset-{self.zone.name}." + shutil.copy(src, Path(target_dir)) + debug(f"{self.zone.name}: dsset copied to {target_dir}") + + @staticmethod + def generate( + zone: Zone, + params: str = "", + alg: Algorithm | None = None, + ) -> FileZoneKey: + """ + Generate a DNSSEC key via dnssec-keygen for zone and return it. + + Runs dnssec-keygen in zone.ns.name/keys/, stores the key there, and + returns the resulting FileZoneKey. Pass params="-f KSK" to generate a + Key Signing Key; omit it (or pass "") for a Zone Signing Key. + """ + debug(f"{zone.name}: generating key using dnssec-keygen") + keydir = Path(zone.ns.name) / KEYDIR + keydir.mkdir(exist_ok=True) + if alg is None: + alg = Algorithm.default() + keygen = EnvCmd( + "KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L {DNSKEY_TTL}" + ) + key_name = keygen(f"{params} {zone.name}", cwd=zone.ns.name).out.strip() + return FileZoneKey(Key(key_name, keydir=keydir), zone=zone) + + +class PythonZoneKey(ZoneKey): + """ + A ZoneKey holding a Python-native keypair. + + Construct via PythonZoneKey.generate() to create fresh key + material, or instantiate directly when you already have a private key and + dnskey rdata (e.g. when loading a saved PEM). + + Attach to a Zone via zone.keys = [key] so that Zone.copy_dssets() can + generate the dsset-* file for the parent zone and Zone.trust_anchors() can + produce the correct trust anchor stanzas. + + Zone.sign() raises TypeError if self.keys contains a PythonZoneKey, + because dnssec-signzone cannot use in-memory keys. Sign the zone + with dns.dnssec.sign_zone() directly instead. + + The raw private key object is available as .private_key for callers that + need it (e.g. to write a PEM file for a custom authoritative server). + Use write_private_key_pem() as a convenience for the common case. + """ + + def __init__( + self, + zone: Zone, + private_key, + dnskey_rdata, + ttl: int = DNSKEY_TTL, + ) -> None: + self.zone = zone + self.private_key = private_key + self._dnskey_rdata = dnskey_rdata + self.ttl = ttl + + @property + def dnskey(self) -> dns.rrset.RRset: + rrset = dns.rrset.RRset( + self.zone.dname, dns.rdataclass.IN, dns.rdatatype.DNSKEY + ) + rrset.update_ttl(self.ttl) + rrset.add(self._dnskey_rdata) + return rrset + + def write_dsset( + self, + target_dir: Path | str, + dsdigest: dns.dnssec.DSDigest = dns.dnssec.DSDigest.SHA256, + ) -> None: + target = Path(target_dir) + target.mkdir(parents=True, exist_ok=True) + ds = dns.dnssec.make_ds(self.zone.dname, self._dnskey_rdata, dsdigest) + text = ( + f"{self.zone.name}. {self.ttl} IN DS" + f" {ds.key_tag} {ds.algorithm} {ds.digest_type}" + f" {ds.digest.hex().upper()}\n" + ) + with (target / f"dsset-{self.zone.name}.").open("a") as f: + f.write(text) + + def write_private_key_pem(self, path: Path | str) -> None: + """Write the private key to path in PKCS8 PEM format (no encryption).""" + Path(path).write_bytes( + self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + @staticmethod + def generate( + zone: Zone, + flags: int = int(Flag.ZONE | Flag.SEP), + alg: Algorithm | None = None, + ttl: int = DNSKEY_TTL, + ) -> PythonZoneKey: + """ + Generate a Python-native DNSSEC keypair for the given algorithm. + + Unlike FileZoneKey.generate(), this does not invoke + dnssec-keygen and produces no on-disk key files. The returned + PythonZoneKey is suitable for use with dns.dnssec.sign(), + dns.dnssec.sign_zone(), and dns.dnssec.make_ds(). + + The algorithm-to-key-type mapping is: + ECDSAP256SHA256 -> EC P-256 + ECDSAP384SHA384 -> EC P-384 + ED25519 -> Ed25519 + ED448 -> Ed448 + RSASHA1/256/512 -> RSA (key size from alg.bits) + + Args: + zone: The Zone to generate the keypair for. + flags: DNSKEY flags bitmask; defaults to ZONE|SEP (KSK). + alg: Algorithm to use; defaults to Algorithm.default(). + ttl: TTL for the DNSKEY RRset (default DNSKEY_TTL). + """ + if alg is None: + alg = Algorithm.default() + _generators: dict[str, Callable[[], PrivateKey]] = { + "ECDSAP256SHA256": lambda: ec.generate_private_key(ec.SECP256R1()), + "ECDSAP384SHA384": lambda: ec.generate_private_key(ec.SECP384R1()), + "ED25519": ed25519.Ed25519PrivateKey.generate, + "ED448": ed448.Ed448PrivateKey.generate, + "RSASHA1": lambda: rsa.generate_private_key(65537, alg.bits), + "RSASHA256": lambda: rsa.generate_private_key(65537, alg.bits), + "RSASHA512": lambda: rsa.generate_private_key(65537, alg.bits), + } + gen = _generators.get(alg.name) + if gen is None: + raise ValueError( + f"unsupported algorithm for Python-native key generation: {alg.name!r}" + ) + private_key = gen() + dnskey_rdata = dns.dnssec.make_dnskey( + private_key.public_key(), + dns.dnssec.Algorithm(alg.number), + flags=flags, + ) + return PythonZoneKey(zone, private_key, dnskey_rdata, ttl) class Zone: @@ -72,42 +332,37 @@ class Zone: ) self.delegations: list[Zone] = [] - self.keys: list[Key] = [] + self.keys: list[ZoneKey] = [] @property def filepath(self) -> Path: """Actual zone file — filepath_signed if signed, filepath_unsigned otherwise.""" return self.filepath_signed if self.signed else self.filepath_unsigned - def make_key(self, params: str = "", alg: Algorithm | None = None) -> Key: - """Generate a DNSSEC key and return it without adding it to self.keys.""" - debug(f"{self.name}: generating key") - keydir = Path(self.ns.name) / KEYDIR - keydir.mkdir(exist_ok=True) - if alg is None: - alg = Algorithm.default() - keygen = EnvCmd( - "KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L 3600" - ) - key_name = keygen(f"{params} {self.name}", cwd=self.ns.name).out.strip() - return Key(key_name, keydir=keydir) - - def add_keys(self, ksk=True, zsk=True) -> None: - """Generate KSK and/or ZSK and append both to self.keys.""" + def add_keys(self, ksk: bool = True, zsk: bool = True) -> None: + """Generate KSK and/or ZSK via dnssec-keygen and append to self.keys.""" if ksk: - self.keys.append(self.make_key("-f KSK")) + self.keys.append(FileZoneKey.generate(self, "-f KSK")) if zsk: - self.keys.append(self.make_key()) + self.keys.append(FileZoneKey.generate(self)) def copy_dssets(self) -> None: - """Copy dsset-* files from each delegation's ns dir into self.ns dir.""" + """Write dsset-* files for each signed delegation into self.ns dir.""" for zone in self.delegations: - try: - shutil.copy(f"{zone.ns.name}/dsset-{zone.name}.", self.ns.name) - except FileNotFoundError: - debug(f"{zone.name}: delegation is insecure (no dsset found)") + ksks = [k for k in zone.keys if k.is_ksk()] + has_file = any(isinstance(k, FileZoneKey) for k in ksks) + has_python = any(isinstance(k, PythonZoneKey) for k in ksks) + if has_file and has_python: + raise TypeError( + f"{zone.name}: cannot mix FileZoneKey and PythonZoneKey KSKs; " + "dsset writing is order-dependent (FileZoneKey overwrites, " + "PythonZoneKey appends)" + ) + if ksks: + for key in ksks: + key.write_dsset(Path(self.ns.name)) else: - debug(f"{zone.name}: delegation is secure") + debug(f"{zone.name}: delegation is insecure (no KSK)") def render(self, template: str | None = None) -> None: """Render the unsigned zone file from a jinja2 template.""" @@ -128,8 +383,20 @@ class Zone: templates.render(str(output), data, template=template) def sign(self, params: str = "") -> None: - """Sign the rendered zone file. Requires self.signed == True.""" + """ + Sign the rendered zone file via dnssec-signzone. + + Requires self.signed == True. Raises TypeError if self.keys contains + any PythonZoneKey — dnssec-signzone cannot use in-memory keys; use + dns.dnssec.sign_zone() directly for Python-native signing. + """ assert self.signed, f"{self.name}: zone is not configured for signing" + python_keys = [k for k in self.keys if isinstance(k, PythonZoneKey)] + if python_keys: + raise TypeError( + f"{self.name}: Zone.sign() invokes dnssec-signzone which requires " + "file-backed keys; use dns.dnssec.sign_zone() for Python-native keys" + ) debug(f"{self.name}: signing zone") signer = EnvCmd("SIGNER", f"-S -g -K {KEYDIR} {params}") signer( @@ -156,11 +423,11 @@ class Zone: if self.signed: self.sign(sign_params) - def trust_anchor( - self, type: str = "static-ds" # pylint: disable=redefined-builtin - ) -> TrustAnchor: - assert self.keys, "no zone keys configured" - return self.keys[0].into_ta(type) + def trust_anchors(self, ta_type: str = "static-ds") -> list[TrustAnchor]: + """Return a trust-anchor stanza for every KSK in zone.keys.""" + ksks = [k for k in self.keys if k.is_ksk()] + assert ksks, f"{self.name}: no KSK in zone.keys" + return [k.into_ta(ta_type) for k in ksks] def configure_root(