Commit 6a4ba15807 for bind
commit 6a4ba15807934fd703bf8f26810506025fc83073
Author: Michał Kępień <michal@isc.org>
Date: Fri Oct 30 14:39:01 2026 +0100
Simplify dnssec_nsec3_delegation test
Replace boilerplate code signing the zone 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 4150f82ea8..f840b99c24 100644
--- a/bin/tests/system/dnssec_nsec3/ans1/ans.py
+++ b/bin/tests/system/dnssec_nsec3/ans1/ans.py
@@ -60,12 +60,8 @@ class AncestorWildcardNsec3Handler(ResponseHandler):
def main() -> None:
- keys = common.load_keys()
- server = AsyncDnsServer(default_aa=True)
- server.install_response_handlers(
- AncestorWildcardNsec3Handler(),
- f055.F055Handler(keys),
- )
+ server = AsyncDnsServer()
+ server.install_response_handlers(AncestorWildcardNsec3Handler())
server.run()
diff --git a/bin/tests/system/dnssec_nsec3/ans1/common.py b/bin/tests/system/dnssec_nsec3/ans1/common.py
deleted file mode 100644
index a9699dc091..0000000000
--- a/bin/tests/system/dnssec_nsec3/ans1/common.py
+++ /dev/null
@@ -1,116 +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 dataclasses import dataclass
-from pathlib import Path
-
-import json
-
-from cryptography.hazmat.primitives import serialization
-
-import dns.dnssec
-import dns.flags
-import dns.message
-import dns.name
-import dns.rcode
-import dns.rdata
-import dns.rdataclass
-import dns.rdatatype
-import dns.rrset
-
-from isctest.asyncserver import QueryContext
-
-TTL = 300
-
-
-@dataclass(frozen=True)
-class Key:
- zone: dns.name.Name
- private_key: object
- dnskey: dns.rdata.Rdata
- ds: dns.rdata.Rdata
-
-
-def name(text: str) -> dns.name.Name:
- return dns.name.from_text(text)
-
-
-def load_keys() -> dict[str, Key]:
- path = Path(".") / "keys.json"
- keys = raw_keys = {}
-
- try:
- with path.open(encoding="utf-8") as keys_file:
- raw_keys = json.load(keys_file)
- except Exception: # pylint: disable=broad-except
- pass
-
- for zone, raw_key in raw_keys.items():
- private_key = serialization.load_pem_private_key(
- raw_key["private_pem"].encode("ascii"),
- password=None,
- )
- dnskey = dns.rdata.from_text(
- dns.rdataclass.IN, dns.rdatatype.DNSKEY, raw_key["dnskey"]
- )
- ds = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, raw_key["ds"])
- keys[zone] = Key(name(zone), private_key, dnskey, ds)
-
- return keys
-
-
-def rrset(owner: str, rdtype: dns.rdatatype.RdataType, *rdatas: str) -> dns.rrset.RRset:
- return dns.rrset.from_text(owner, TTL, dns.rdataclass.IN, rdtype, *rdatas)
-
-
-def rrset_from_rdata(owner: str, rdata: dns.rdata.Rdata) -> dns.rrset.RRset:
- return dns.rrset.from_rdata(owner, TTL, rdata)
-
-
-def add_signed(
- section: list[dns.rrset.RRset], covered: dns.rrset.RRset, signer: Key
-) -> None:
- rrsig = dns.dnssec.sign(
- covered,
- signer.private_key,
- signer.zone,
- signer.dnskey,
- lifetime=86400,
- verify=True,
- )
- section.append(covered)
- section.append(dns.rrset.from_rdata(covered.name, covered.ttl, rrsig))
-
-
-def soa_rrset(zone) -> dns.rrset.RRset:
- return rrset(
- zone,
- dns.rdatatype.SOA,
- f"ns.{zone} hostmaster.{zone} 1 3600 600 86400 300",
- )
-
-
-def prepare_response(qctx: QueryContext) -> dns.message.Message:
- qctx.prepare_new_response(with_zone_data=False)
- qctx.response.flags |= dns.flags.AA
- qctx.response.set_rcode(dns.rcode.NOERROR)
- return qctx.response
-
-
-def nsec3_hash(owner: str) -> str:
- return dns.dnssec.nsec3_hash(owner, salt=None, iterations=0, algorithm=1).upper()
-
-
-def nsec3_rrset(
- zone: str, owner_hash: str, next_hash: str, flags: int, *types: str
-) -> dns.rrset.RRset:
- rdata = f"1 {flags} 0 - {next_hash} {' '.join(types)}"
- return rrset(f"{owner_hash}.{zone}", dns.rdatatype.NSEC3, rdata)
diff --git a/bin/tests/system/dnssec_nsec3/ans1/f055.py b/bin/tests/system/dnssec_nsec3/ans1/f055.py
deleted file mode 100644
index 3aff055ace..0000000000
--- a/bin/tests/system/dnssec_nsec3/ans1/f055.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
-from datetime import datetime, timedelta, timezone
-
-import base64
-
-import dns.dnssec
-import dns.message
-import dns.rcode
-import dns.rdata
-import dns.rdataclass
-import dns.rdatatype
-import dns.rrset
-
-from dnssec_nsec3.ans1.common import (
- Key,
- add_signed,
- name,
- nsec3_hash,
- nsec3_rrset,
- prepare_response,
- 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
-F055_ZONE = "f055.test."
-SAFE = f"safe.{F055_ZONE}"
-CUT = f"cut.{F055_ZONE}"
-CONTROL = f"ctl.{F055_ZONE}"
-ATTACK = f"a.{CUT}"
-CONTROL_ATTACK = f"a.{CONTROL}"
-SAFE_A = "198.51.100.55"
-FORGED_A = "192.0.2.55"
-
-
-def garbage_rrsig(covered: dns.rrset.RRset, signer: Key) -> dns.rrset.RRset:
- now = datetime.now(timezone.utc)
- inception = (now - timedelta(hours=1)).strftime("%Y%m%d%H%M%S")
- expiration = (now + timedelta(days=1)).strftime("%Y%m%d%H%M%S")
- signature = base64.b64encode(bytes(64)).decode("ascii")
- labels = len(covered.name.labels) - 1
- text = (
- f"{dns.rdatatype.to_text(covered.rdtype)} "
- f"13 {labels} {covered.ttl} {expiration} {inception} "
- f"{dns.dnssec.key_id(signer.dnskey)} {F055_ZONE} {signature}"
- )
- rdata = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.RRSIG, text)
- return dns.rrset.from_rdata(covered.name, covered.ttl, rdata)
-
-
-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")
-
-
-def forged_cut_nsec3() -> dns.rrset.RRset:
- cut_hash = nsec3_hash(CUT)
- return nsec3_rrset(
- F055_ZONE,
- base32hex_add(cut_hash, -1),
- base32hex_add(cut_hash, 1),
- 1,
- "A",
- "RRSIG",
- )
-
-
-@dataclass(frozen=True)
-class ChainEntry:
- owner: str
- owner_hash: str
- types: tuple[str, ...]
-
-
-class Nsec3Chain:
- def __init__(self) -> None:
- entries = [
- (F055_ZONE, ("NS", "SOA", "RRSIG", "DNSKEY", "NSEC3PARAM")),
- (SAFE, ("A", "RRSIG")),
- (CUT, ("A", "RRSIG")),
- (CONTROL, ("A", "RRSIG")),
- ]
- self.entries = sorted(
- (ChainEntry(owner, nsec3_hash(owner), types) for owner, types in entries),
- key=lambda entry: entry.owner_hash,
- )
- self.by_owner = {entry.owner: entry for entry in self.entries}
-
- def rrset_for(self, owner: str) -> dns.rrset.RRset:
- entry = self.by_owner[owner]
- index = self.entries.index(entry)
- next_hash = self.entries[(index + 1) % len(self.entries)].owner_hash
- return nsec3_rrset(F055_ZONE, entry.owner_hash, next_hash, 0, *entry.types)
-
- def cover_for(self, owner: str) -> dns.rrset.RRset:
- owner_hash = nsec3_hash(owner)
- for index, entry in enumerate(self.entries):
- next_hash = self.entries[(index + 1) % len(self.entries)].owner_hash
- if entry.owner_hash < next_hash:
- if entry.owner_hash < owner_hash < next_hash:
- return self.rrset_for(entry.owner)
- elif owner_hash > entry.owner_hash or owner_hash < next_hash:
- return self.rrset_for(entry.owner)
- return self.rrset_for(self.entries[-1].owner)
-
-
-def add_signed_nsec3(
- section: list[dns.rrset.RRset],
- chain: Nsec3Chain,
- owner: str,
- signer: Key,
-) -> None:
- add_signed(section, chain.rrset_for(owner), signer)
-
-
-def add_nsec3_nxdomain(
- response: dns.message.Message,
- chain: Nsec3Chain,
- closest: str,
- qname: str,
- signer: Key,
-) -> None:
- response.set_rcode(dns.rcode.NXDOMAIN)
- add_signed(response.authority, soa_rrset(F055_ZONE), signer)
- add_signed_nsec3(response.authority, chain, closest, signer)
- add_signed(response.authority, chain.cover_for(qname), signer)
- add_signed(response.authority, chain.cover_for(f"*.{closest}"), signer)
-
-
-class F055Handler(ResponseHandler):
- matcher = Domain(F055_ZONE)
-
- def __init__(self, keys: dict[str, Key]) -> None:
- super().__init__()
- self.keys = keys
-
- if F055_ZONE not in keys:
- return
-
- self.zone_key = keys[F055_ZONE]
- self.chain = Nsec3Chain()
- self.zone = name(F055_ZONE)
- self.safe = name(SAFE)
- self.cut = name(CUT)
- self.control = name(CONTROL)
- self.attack = name(ATTACK)
- self.control_attack = name(CONTROL_ATTACK)
-
- async def get_responses(
- self, qctx: QueryContext
- ) -> AsyncGenerator[DnsResponseSend, None]:
- response = prepare_response(qctx)
- qname = qctx.qname.to_text()
-
- if qctx.qname == self.zone and qctx.qtype == dns.rdatatype.DNSKEY:
- # Priming, zone DNSKEY
- add_signed(
- response.answer,
- rrset_from_rdata(F055_ZONE, self.zone_key.dnskey),
- self.zone_key,
- )
- elif qctx.qname == self.zone and qctx.qtype == dns.rdatatype.SOA:
- # Priming, zone SOA
- add_signed(response.answer, soa_rrset(F055_ZONE), self.zone_key)
- elif qctx.qname == self.safe and qctx.qtype == dns.rdatatype.A:
- # Safe, good answer
- add_signed(
- response.answer, rrset(SAFE, dns.rdatatype.A, SAFE_A), self.zone_key
- )
- elif qctx.qname == self.cut and qctx.qtype == dns.rdatatype.DS:
- # Forged DS answer
- forged = forged_cut_nsec3()
- response.authority.append(forged)
- response.authority.append(garbage_rrsig(forged, self.zone_key))
- add_signed_nsec3(response.authority, self.chain, CUT, self.zone_key)
- add_signed(response.authority, soa_rrset(F055_ZONE), self.zone_key)
- elif qctx.qname == self.control and qctx.qtype == dns.rdatatype.DS:
- # Good DS answer
- add_signed_nsec3(response.authority, self.chain, CONTROL, self.zone_key)
- add_signed(response.authority, soa_rrset(F055_ZONE), self.zone_key)
- elif qctx.qname in {self.attack, self.control_attack}:
- # Attack query for CUT/CONTROL
- if qctx.qtype == dns.rdatatype.A:
- response.answer.append(rrset(qname, dns.rdatatype.A, FORGED_A))
- elif qctx.qtype == dns.rdatatype.DS:
- closest = CUT if qctx.qname == self.attack else CONTROL
- add_nsec3_nxdomain(
- response,
- self.chain,
- closest,
- qname,
- self.zone_key,
- )
- else:
- response.authority.append(soa_rrset(F055_ZONE))
- elif qctx.qname.is_subdomain(self.cut):
- # NXDOMAIN the rest
- add_nsec3_nxdomain(response, self.chain, CUT, qname, self.zone_key)
- elif qctx.qname.is_subdomain(self.control):
- # NXDOMAIN the rest
- add_nsec3_nxdomain(response, self.chain, CONTROL, qname, self.zone_key)
- else:
- # NXDOMAIN the rest
- add_nsec3_nxdomain(response, self.chain, F055_ZONE, qname, self.zone_key)
-
- yield DnsResponseSend(response, authoritative=True)
diff --git a/bin/tests/system/dnssec_nsec3/ans3/ans.py b/bin/tests/system/dnssec_nsec3/ans3/ans.py
new file mode 100644
index 0000000000..5db54fd514
--- /dev/null
+++ b/bin/tests/system/dnssec_nsec3/ans3/ans.py
@@ -0,0 +1,100 @@
+# 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
+
+import dns.name
+import dns.rcode
+import dns.rdatatype
+import dns.rrset
+
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
+from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.handlers import StaticResponseHandler
+from isctest.asyncserver.matchers import Qname, Qtype
+
+from ..dnssec_nsec3_ans import minimal_covering_nsec3, rrset
+
+
+def a(owner: str | dns.name.Name, address: str = "192.0.2.55") -> dns.rrset.RRset:
+ return rrset(owner, dns.rdatatype.A, address)
+
+
+class ControlAHandler(StaticResponseHandler):
+ """
+ This handler imitates successfully spoofing an unsigned response to an
+ "a.ctl.f055.test/A" query sent to a server authoritative for the
+ "f055.test." domain. The subsequent "ctl.f055.test/DS" query sent by the
+ resolver is responded to with a properly signed NODATA response prepared
+ from zone data. These two responses combined demonstrate that named
+ refuses to treat an unsigned answer to the A query as legitimate when the
+ DS non-existence proof is properly signed.
+ """
+
+ matcher = Qname("a.ctl.f055.test.") & Qtype(dns.rdatatype.A)
+ answer = [a(matcher.of(Qname).qnames[0])]
+
+
+class AttackAHandler(StaticResponseHandler):
+ """
+ This handler imitates successfully spoofing an unsigned response to an
+ "a.cut.f055.test/A" query sent to a server authoritative for the
+ "f055.test." domain. The subsequent "cut.f055.test/DS" query sent by the
+ resolver is responded to by AttackDsHandler, which makes a vulnerable
+ resolver treat an unsigned answer to the A query as a legitimate insecure
+ response.
+ """
+
+ matcher = Qname("a.cut.f055.test.") & Qtype(dns.rdatatype.A)
+ answer = [a(matcher.of(Qname).qnames[0])]
+
+
+class AttackDsHandler(ResponseHandler):
+ """
+ This handler imitates successfully spoofing a response to a
+ "cut.f055.test/DS" query sent to a server authoritative for the
+ "f055.test." domain. The spoofed response contains a properly signed
+ zone-based NODATA response (which can be replayed by an attacker) with an
+ extra forged NSEC3 record also proving that the DS record does not exist,
+ but accompanied by a bogus RRSIG record. A vulnerable resolver treats such
+ a broken non-existence proof as sufficient for returning an unsigned answer
+ to the A query to the client.
+ """
+
+ matcher = Qname("cut.f055.test.") & Qtype(dns.rdatatype.DS)
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[DnsResponseSend, None]:
+ assert qctx.zone
+ assert qctx.zone.origin
+ forged_nsec3_covering_rrset = minimal_covering_nsec3(
+ qctx.qname, qctx.zone.origin, opt_out=True
+ )
+ forged_nsec3_covering_rrsig = qctx.sign(forged_nsec3_covering_rrset, bogus=True)
+ qctx.response.authority.append(forged_nsec3_covering_rrset)
+ qctx.response.authority.append(forged_nsec3_covering_rrsig)
+
+ yield DnsResponseSend(qctx.response)
+
+
+def main() -> None:
+ server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR)
+ server.install_response_handlers(
+ ControlAHandler(),
+ AttackAHandler(),
+ AttackDsHandler(),
+ )
+ server.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/bin/tests/system/dnssec_nsec3/ans3/zones/f055.test.db.j2.manual b/bin/tests/system/dnssec_nsec3/ans3/zones/f055.test.db.j2.manual
new file mode 100644
index 0000000000..e54de017ab
--- /dev/null
+++ b/bin/tests/system/dnssec_nsec3/ans3/zones/f055.test.db.j2.manual
@@ -0,0 +1,5 @@
+{% include "_common/zones/template.db.j2.manual" %}
+
+safe A 198.51.100.55
+ctl A 127.0.0.1
+cut A 127.0.0.1
diff --git a/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py b/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
index ff07b20e0e..71703c7eef 100644
--- a/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
+++ b/bin/tests/system/dnssec_nsec3/dnssec_nsec3_ans.py
@@ -41,7 +41,7 @@ def rrset(
def minimal_covering_nsec3(
- name: dns.name.Name, origin: dns.name.Name
+ name: dns.name.Name, origin: dns.name.Name, opt_out: bool = False
) -> dns.rrset.RRset:
nsec3_hash = dns.dnssec.nsec3_hash(name, None, 0, 1)
nsec3_hash_just_before = base32hex_delta(nsec3_hash, -1)
@@ -50,5 +50,5 @@ def minimal_covering_nsec3(
return rrset(
nsec3_owner,
dns.rdatatype.NSEC3,
- f"1 0 0 - {nsec3_hash_just_after} TXT RRSIG",
+ f'1 {"1" if opt_out else "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 9c3a3e40ca..2d3b399ae7 100644
--- a/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
+++ b/bin/tests/system/dnssec_nsec3/ns2/named.conf.j2
@@ -18,13 +18,7 @@ zone "f025.test" {
zone "f055.test" {
type static-stub;
- server-addresses { 10.53.0.1; };
+ server-addresses { 10.53.0.3; };
};
-{% if ZONE_DNSKEY is defined %}
-trust-anchors {
- 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_dnssec_nsec3_delegation.py b/bin/tests/system/dnssec_nsec3/tests_dnssec_nsec3_delegation.py
index 95e6222e78..3ed720b25a 100644
--- a/bin/tests/system/dnssec_nsec3/tests_dnssec_nsec3_delegation.py
+++ b/bin/tests/system/dnssec_nsec3/tests_dnssec_nsec3_delegation.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
F055_ZONE = "f055.test."
SAFE = f"safe.{F055_ZONE}"
@@ -33,44 +27,26 @@ ATTACK = f"a.{CUT}"
CONTROL_ATTACK = f"a.{CONTROL}"
SAFE_A = "198.51.100.55"
FORGED_A = "192.0.2.55"
-AUTH = "10.53.0.1"
-RESOLVER = "10.53.0.2"
+AUTH = isctest.template.ANS3.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: str):
- 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():
- keys = {F055_ZONE: _make_key(F055_ZONE)}
- Path("ans1/keys.json").write_text(json.dumps(keys, indent=2), encoding="ascii")
- zone_dnskey = "".join(keys[F055_ZONE]["dnskey"].split()[3:])
- return {"ZONE_DNSKEY": zone_dnskey}
+ zone = isctest.zone.Zone("f055.test.", isctest.template.ANS3, signed=True)
+ zone.configure(csk=True, sign_params="-3 -")
+
+ return {"trust_anchors": zone.trust_anchors()}
def _query(server, qname, qtype):