Commit a417bade7f for bind

commit a417bade7f1f81733359c2c72b91ed32a3271bdd
Author: Michał Kępień <michal@isc.org>
Date:   Fri Oct 30 14:39:01 2026 +0100

    Simplify nsec3_wrong_zone test

    Replace boilerplate code signing the zones used by the ans1 custom
    server with canonical bootstrap() code employing isctest.zone.Zone.

    Replace boilerplate code crafting DNSSEC responses in ans1/*.py with
    zone file data in ans1/zones/ signed in bootstrap(), reducing
    ans1/ans.py down to just the logic implementing the attack attempted by
    the test.

    Replace fixed IP addresses with constants exposed by the
    isctest.template module.

diff --git a/bin/tests/system/dnssec_nsec3/ans1/ans.py b/bin/tests/system/dnssec_nsec3/ans1/ans.py
index 2d24d71a9f..4150f82ea8 100644
--- a/bin/tests/system/dnssec_nsec3/ans1/ans.py
+++ b/bin/tests/system/dnssec_nsec3/ans1/ans.py
@@ -9,15 +9,63 @@
 # See the COPYRIGHT file distributed with this work for additional
 # information regarding copyright ownership.

-from dnssec_nsec3.ans1 import common, f025, f055
-from isctest.asyncserver import AsyncDnsServer
+from collections.abc import AsyncGenerator
+
+import dns.rdatatype
+
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
+from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.matchers import Qname, Qtype
+
+from ..dnssec_nsec3_ans import minimal_covering_nsec3
+
+
+class AncestorWildcardNsec3Handler(ResponseHandler):
+    """
+    This handler imitates successfully spoofing a response to a
+    "b.victim2.evil.f025.test/A" query sent to a server authoritative for the
+    "evil.f025.test." domain.  The spoofed response contains the wildcard A
+    record from the "evil.f025.test." zone and a forged NSEC3 record proving
+    that the QNAME's parent (and thus also the QNAME itself) does not exist and
+    therefore the wildcard match is valid.  However, the forged NSEC3 record is
+    signed using the DNSKEY belonging to the parent zone ("f025.test.").  A
+    vulnerable resolver validates such a response as secure, enabling names in
+    the child zone to be redirected to that zone's wildcard target using the
+    parent zone's signing key.
+    """
+
+    matcher = Qname("b.victim2.evil.f025.test.") & Qtype(dns.rdatatype.A)
+
+    async def get_responses(
+        self, qctx: QueryContext
+    ) -> AsyncGenerator[DnsResponseSend, None]:
+        assert qctx.zone
+        assert qctx.zone.origin
+        parent_origin = qctx.zone.origin.parent()
+        parent_signing_key = qctx.keys[parent_origin][0]
+
+        forged_nsec3_covering_rrset = minimal_covering_nsec3(
+            qctx.qname.parent(), parent_origin
+        )
+        forged_nsec3_covering_rrsig = qctx.sign(
+            forged_nsec3_covering_rrset, key=parent_signing_key
+        )
+
+        qctx.response.authority = [
+            forged_nsec3_covering_rrset,
+            forged_nsec3_covering_rrsig,
+        ]
+
+        yield DnsResponseSend(qctx.response)


 def main() -> None:
     keys = common.load_keys()
     server = AsyncDnsServer(default_aa=True)
-    server.install_response_handler(f025.F025Handler(keys))
-    server.install_response_handler(f055.F055Handler(keys))
+    server.install_response_handlers(
+        AncestorWildcardNsec3Handler(),
+        f055.F055Handler(keys),
+    )
     server.run()


diff --git a/bin/tests/system/dnssec_nsec3/ans1/f025.py b/bin/tests/system/dnssec_nsec3/ans1/f025.py
deleted file mode 100644
index 953a5ad221..0000000000
--- a/bin/tests/system/dnssec_nsec3/ans1/f025.py
+++ /dev/null
@@ -1,224 +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 collections.abc import AsyncGenerator
-from dataclasses import dataclass
-
-import base64
-
-import dns.dnssec
-import dns.flags
-import dns.message
-import dns.rcode
-import dns.rdatatype
-import dns.rrset
-
-from dnssec_nsec3.ans1.common import (
-    Key,
-    add_signed,
-    name,
-    nsec3_hash,
-    nsec3_rrset,
-    rrset,
-    rrset_from_rdata,
-    soa_rrset,
-)
-from isctest.asyncserver import QueryContext, ResponseHandler
-from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.matchers import Domain
-
-TTL = 300
-PARENT = "f025.test."
-CHILD = f"evil.{PARENT}"
-PARENT_NS = f"ns.{PARENT}"
-CHILD_NS = f"ns.{CHILD}"
-CLOSEST = f"victim2.{CHILD}"
-ATTACK = f"b.{CLOSEST}"
-LEGIT = f"legit.{CHILD}"
-WILDCARD = f"*.{CHILD}"
-FORGED_A = "6.6.6.6"
-
-
-@dataclass(frozen=True)
-class Nsec3Entry:
-    owner: str
-    owner_hash: str
-    types: tuple[str, ...]
-
-
-def base32hex_add(hash_text: str, delta: int) -> str:
-    raw = bytearray(base64.b32hexdecode(hash_text.upper()))
-    value = int.from_bytes(raw, "big") + delta
-    value %= 1 << (8 * len(raw))
-    return base64.b32hexencode(value.to_bytes(len(raw), "big")).decode("ascii")
-
-
-class Nsec3Chain:
-    def __init__(self, zone: str, entries: list[tuple[str, tuple[str, ...]]]) -> None:
-        self.zone = zone
-        self.entries = sorted(
-            [Nsec3Entry(owner, nsec3_hash(owner), types) for owner, types in entries],
-            key=lambda entry: entry.owner_hash,
-        )
-
-    def rrset_for_entry(self, entry: Nsec3Entry) -> dns.rrset.RRset:
-        index = self.entries.index(entry)
-        next_hash = self.entries[(index + 1) % len(self.entries)].owner_hash
-        return nsec3_rrset(self.zone, entry.owner_hash, next_hash, 0, *entry.types)
-
-    def rrsets(self) -> list[dns.rrset.RRset]:
-        return [self.rrset_for_entry(entry) for entry in self.entries]
-
-
-def add_nsec3_chain(
-    section: list[dns.rrset.RRset], chain: Nsec3Chain, signer: Key
-) -> None:
-    for covered in chain.rrsets():
-        add_signed(section, covered, signer)
-
-
-def add_tight_parent_nsec3(section: list[dns.rrset.RRset], parent: Key) -> None:
-    target_hash = nsec3_hash(f"{CLOSEST}")
-    covered = nsec3_rrset(
-        PARENT,
-        base32hex_add(target_hash, -1),
-        base32hex_add(target_hash, 1),
-        0,
-        "TXT",
-        "RRSIG",
-    )
-    add_signed(section, covered, parent)
-
-
-def wildcard_rrsig(owner: str, child: Key) -> dns.rrset.RRset:
-    wildcard = rrset(WILDCARD, dns.rdatatype.A, FORGED_A)
-    rrsig = dns.dnssec.sign(
-        wildcard,
-        child.private_key,
-        child.zone,
-        child.dnskey,
-        lifetime=86400,
-        verify=True,
-    )
-    return dns.rrset.from_rdata(name(owner), wildcard.ttl, rrsig)
-
-
-def add_wildcard_answer(response: dns.message.Message, owner: str, child: Key) -> None:
-    response.answer.append(rrset(owner, dns.rdatatype.A, FORGED_A))
-    response.answer.append(wildcard_rrsig(owner, child))
-
-
-class F025Handler(ResponseHandler):
-    matcher = Domain(PARENT, CHILD)
-
-    def __init__(self, keys: dict[str, Key]) -> None:
-        super().__init__()
-        self.keys = keys
-
-        self.parent = name(PARENT)
-        self.child = name(CHILD)
-        self.parent_ns = name(PARENT_NS)
-        self.child_ns = name(CHILD_NS)
-        self.child_nsec3 = Nsec3Chain(
-            CHILD,
-            [
-                (CHILD, ("NS", "SOA", "RRSIG", "DNSKEY", "NSEC3PARAM")),
-                (WILDCARD, ("A", "RRSIG")),
-                (CHILD_NS, ("A", "RRSIG")),
-            ],
-        )
-
-    def _add_extra_nsec3(self, response: dns.message.Message, qname: str) -> None:
-        parent_key = self.keys[PARENT]
-        child_key = self.keys[CHILD]
-        if "victim2." in qname:
-            add_tight_parent_nsec3(response.authority, parent_key)
-        else:
-            add_nsec3_chain(response.authority, self.child_nsec3, child_key)
-
-    async def get_responses(
-        self, qctx: QueryContext
-    ) -> AsyncGenerator[DnsResponseSend, None]:
-        qctx.prepare_new_response(with_zone_data=False)
-        qctx.response.flags |= dns.flags.AA
-        qctx.response.set_rcode(dns.rcode.NOERROR)
-
-        parent_key = self.keys[PARENT]
-        child_key = self.keys[CHILD]
-        qname = qctx.qname.to_text()
-
-        if qctx.qname == self.parent and qctx.qtype == dns.rdatatype.DNSKEY:
-            # Priming, parent DNSKEY
-            add_signed(
-                qctx.response.answer,
-                rrset_from_rdata(PARENT, parent_key.dnskey),
-                parent_key,
-            )
-        elif qctx.qname == self.parent and qctx.qtype == dns.rdatatype.SOA:
-            # Priming, parent SOA
-            add_signed(qctx.response.answer, soa_rrset(PARENT), parent_key)
-        elif qctx.qname == self.parent and qctx.qtype == dns.rdatatype.NS:
-            # Priming, parent NS
-            add_signed(
-                qctx.response.answer,
-                rrset(PARENT, dns.rdatatype.NS, PARENT_NS),
-                parent_key,
-            )
-        elif qctx.qname == self.parent_ns and qctx.qtype == dns.rdatatype.A:
-            # Priming, parent glue
-            add_signed(
-                qctx.response.answer,
-                rrset(PARENT_NS, dns.rdatatype.A, "10.53.0.1"),
-                parent_key,
-            )
-        elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.DS:
-            # Priming, child DS
-            add_signed(
-                qctx.response.answer,
-                rrset_from_rdata(CHILD, child_key.ds),
-                parent_key,
-            )
-        elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.DNSKEY:
-            # Priming, child DNSKEY
-            add_signed(
-                qctx.response.answer,
-                rrset_from_rdata(CHILD, child_key.dnskey),
-                child_key,
-            )
-        elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.SOA:
-            # Priming, child SOA
-            add_signed(qctx.response.answer, soa_rrset(CHILD), child_key)
-        elif qctx.qname == self.child and qctx.qtype == dns.rdatatype.NS:
-            # Priming, child NS
-            add_signed(
-                qctx.response.answer,
-                rrset(CHILD, dns.rdatatype.NS, CHILD_NS),
-                child_key,
-            )
-        elif qctx.qname == self.child_ns and qctx.qtype == dns.rdatatype.A:
-            # Priming, child glue
-            add_signed(
-                qctx.response.answer,
-                rrset(CHILD_NS, dns.rdatatype.A, "10.53.0.1"),
-                child_key,
-            )
-        elif qctx.qname.is_subdomain(self.child):
-            if qctx.qtype == dns.rdatatype.A:
-                add_wildcard_answer(qctx.response, qname, child_key)
-            else:
-                add_signed(qctx.response.authority, soa_rrset(CHILD), child_key)
-            # Adding malicious NSEC3
-            self._add_extra_nsec3(qctx.response, qname)
-        else:
-            # Everything else is NODATA
-            add_signed(qctx.response.authority, soa_rrset(PARENT), parent_key)
-
-        yield DnsResponseSend(qctx.response, authoritative=True)
diff --git a/bin/tests/system/dnssec_nsec3/ans1/zones/evil.f025.test.db.j2.manual b/bin/tests/system/dnssec_nsec3/ans1/zones/evil.f025.test.db.j2.manual
new file mode 100644
index 0000000000..7e8ca880e1
--- /dev/null
+++ b/bin/tests/system/dnssec_nsec3/ans1/zones/evil.f025.test.db.j2.manual
@@ -0,0 +1,3 @@
+{% include "_common/zones/template.db.j2.manual" %}
+
+*	A	6.6.6.6
diff --git a/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py b/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
new file mode 100644
index 0000000000..ff07b20e0e
--- /dev/null
+++ b/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
@@ -0,0 +1,54 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+import base64
+
+import dns.dnssec
+import dns.name
+import dns.rdataclass
+import dns.rdatatype
+import dns.rrset
+
+
+def base32hex_delta(base32_hash: str, delta: int) -> str:
+    base32_hash_raw = base64.b32hexdecode(base32_hash)
+    base32_hash_raw_length = len(base32_hash_raw)
+    base32_hash_value = int.from_bytes(base32_hash_raw, "big")
+    base32_hash_value_with_delta = base32_hash_value + delta
+    base32_hash_value_with_delta %= 1 << (8 * base32_hash_raw_length)
+    base32_hash_raw_with_delta = base32_hash_value_with_delta.to_bytes(
+        base32_hash_raw_length, "big"
+    )
+    base32_hash_with_delta = base64.b32hexencode(base32_hash_raw_with_delta)
+    return base32_hash_with_delta.decode("ascii")
+
+
+def rrset(
+    owner: dns.name.Name | str,
+    rtype: dns.rdatatype.RdataType,
+    rdata: str,
+    ttl: int = 300,
+) -> dns.rrset.RRset:
+    return dns.rrset.from_text(owner, ttl, dns.rdataclass.IN, rtype, rdata)
+
+
+def minimal_covering_nsec3(
+    name: dns.name.Name, origin: dns.name.Name
+) -> dns.rrset.RRset:
+    nsec3_hash = dns.dnssec.nsec3_hash(name, None, 0, 1)
+    nsec3_hash_just_before = base32hex_delta(nsec3_hash, -1)
+    nsec3_hash_just_after = base32hex_delta(nsec3_hash, +1)
+    nsec3_owner = dns.name.from_text(nsec3_hash_just_before, origin=origin)
+    return rrset(
+        nsec3_owner,
+        dns.rdatatype.NSEC3,
+        f"1 0 0 - {nsec3_hash_just_after} TXT RRSIG",
+    )
diff --git a/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2 b/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
index 66b07753fa..9c3a3e40ca 100644
--- a/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
+++ b/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
@@ -21,7 +21,10 @@ zone "f055.test" {
 	server-addresses { 10.53.0.1; };
 };

+{% if ZONE_DNSKEY is defined %}
 trust-anchors {
-	f025.test. static-key 257 3 13 "@ZONE_DNSKEY@";
 	f055.test. static-key 257 3 13 "@ZONE_DNSKEY@";
 };
+{% else %}
+{% include "_common/trusted.conf.j2" %}
+{% endif %}
diff --git a/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py b/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py
index 16a1e44a93..47ee6fe5a9 100644
--- a/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py
+++ b/bin/tests/system/dnssec_nsec3/tests_nsec3_wrong_zone.py
@@ -9,14 +9,6 @@
 # See the COPYRIGHT file distributed with this work for additional
 # information regarding copyright ownership.

-from pathlib import Path
-
-import json
-
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import ec
-
-import dns.dnssec
 import dns.name
 import dns.rdataclass
 import dns.rdatatype
@@ -24,6 +16,8 @@ import pytest

 import isctest
 import isctest.mark
+import isctest.template
+import isctest.zone

 PARENT = "f025.test."
 CHILD = f"evil.{PARENT}"
@@ -31,44 +25,30 @@ CLOSEST = f"victim2.{CHILD}"
 ATTACK = f"b.{CLOSEST}"
 LEGIT = f"legit.{CHILD}"
 FORGED_A = "6.6.6.6"
-AUTH = "10.53.0.1"
-RESOLVER = "10.53.0.2"
+AUTH = isctest.template.ANS1.ip
+RESOLVER = isctest.template.NS2.ip

 pytestmark = [
     isctest.mark.with_ecdsa_deterministic,
     pytest.mark.extra_artifacts(
         [
-            "ans*/keys.json",
+            "ans*/dsset-*",
+            "ans*/zones/*.db",
+            "ans*/zones/*.db.signed",
         ]
     ),
 ]


-def _make_key(zone):
-    private_key = ec.generate_private_key(ec.SECP256R1())
-    dnskey = dns.dnssec.make_dnskey(
-        private_key.public_key(),
-        algorithm="ECDSAP256SHA256",
-        flags=257,
-    )
-    ds = dns.dnssec.make_ds(dns.name.from_text(zone), dnskey, "SHA256")
-    private_pem = private_key.private_bytes(
-        encoding=serialization.Encoding.PEM,
-        format=serialization.PrivateFormat.PKCS8,
-        encryption_algorithm=serialization.NoEncryption(),
-    ).decode("ascii")
-    return {
-        "private_pem": private_pem,
-        "dnskey": dnskey.to_text(),
-        "ds": ds.to_text(),
-    }
+def bootstrap():
+    child = isctest.zone.Zone("evil.f025.test.", isctest.template.ANS1, signed=True)
+    child.configure(csk=True, sign_params="-3 -")

+    parent = isctest.zone.Zone("f025.test.", isctest.template.ANS1, signed=True)
+    parent.delegations = [child]
+    parent.configure(csk=True, sign_params="-3 -")

-def bootstrap():
-    keys = {zone: _make_key(zone) for zone in [PARENT, CHILD]}
-    Path("ans1/keys.json").write_text(json.dumps(keys, indent=2), encoding="ascii")
-    zone_dnskey = "".join(keys[PARENT]["dnskey"].split()[3:])
-    return {"ZONE_DNSKEY": zone_dnskey}
+    return {"trust_anchors": parent.trust_anchors()}


 def _query(server, qname, qtype):