new: test: pytest helpers for dnssec and zone setup

- Create `isctest.zone.Zone` helper for zone setup (including signing).
- Add `ZoneKey` helpers for both dnssec-keygen managed keys and python-based keys.
- Add `dnssec_py` shared test setup for DNSSEC tests.
- Add the first example - refactor `nsec3_delegations` into a `dnssec_py` test module.

Merge branch 'nicki/pytest-dnssec-py' into 'main'

See merge request isc-projects/bind9!11807
This commit is contained in:
Nicki Křížek 2026-06-04 19:16:12 +02:00
commit 2e2cbd3a57
18 changed files with 642 additions and 183 deletions

View file

@ -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",

View file

@ -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",
]
)

View file

@ -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" %}

View file

@ -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" %}

View file

@ -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" %}

View file

@ -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" %}

View file

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

View file

@ -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",
]

View file

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

View file

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

View file

@ -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";
};

View file

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

View file

@ -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 %}

View file

@ -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";
};

View file

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

View file

@ -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";

View file

@ -1 +0,0 @@
../../_common/trusted.conf.j2

View file

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