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/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" %} 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/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..df65932541 --- /dev/null +++ b/bin/tests/system/isctest/zone.py @@ -0,0 +1,441 @@ +# 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 __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 +from .run import EnvCmd +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: + """ + 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[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 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(FileZoneKey.generate(self, "-f KSK")) + if zsk: + self.keys.append(FileZoneKey.generate(self)) + + def copy_dssets(self) -> None: + """Write dsset-* files for each signed delegation into self.ns dir.""" + for zone in self.delegations: + 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 insecure (no KSK)") + + 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 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( + 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_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( + 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 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" - )