2
0
mirror of https://gitlab.isc.org/isc-projects/bind9 synced 2025-08-31 14:35:26 +00:00

new: test: Robust tests for NSEC3 nonexistent QNAME proof

Related to #5292

Merge branch '5292-wrong' into 'main'

See merge request isc-projects/bind9!10416
This commit is contained in:
Petr Špaček
2025-07-29 08:54:27 +00:00
13 changed files with 1105 additions and 19 deletions

View File

@@ -4720,30 +4720,51 @@ status=$((status + ret))
echo_i "checking NSEC3 nxdomain response closest encloser with 0 ENT ($n)"
ret=0
dig_with_opts @10.53.0.3 b.b.b.b.b.a.nsec3.example. >dig.out.ns3.test$n
grep "status: NXDOMAIN" dig.out.ns3.test$n >/dev/null || ret=1
pat="^6OVDUHTN094ML2PV8AN90U0DPU823GH2\.nsec3.example\..*NSEC3 1 0 0 - 7AT0S0RIDCJRFF2M5H5AAV22CSFJBUL4 A RRSIG\$"
grep "$pat" dig.out.ns3.test$n >/dev/null || ret=1
dig_with_opts @10.53.0.4 b.b.b.b.b.a.nsec3.example. >dig.out.ns4.test$n
grep "status: NXDOMAIN" dig.out.ns4.test$n >/dev/null || ret=1
# closest encloser (a.nsec3.example)
pat1="^6OVDUHTN094ML2PV8AN90U0DPU823GH2\.nsec3\.example\..*NSEC3 1 0 0 - 7AT0S0RIDCJRFF2M5H5AAV22CSFJBUL4 A RRSIG\$"
grep "$pat1" dig.out.ns4.test$n >/dev/null || ret=1
# no QNAME proof (b.a.nsec3.example / DSPF4R9UKOEPJ9O34E1H4539LSOTL14E)
pat2="^CG2DVCNE20EKU1PDRLMI2L4DGC2FO1H3\.nsec3\.example\..*NSEC3 1 0 0 - EF2S05SGK1IR2K5SKMFIRERGQCLMR18M A RRSIG\$"
grep "$pat2" dig.out.ns4.test$n >/dev/null || ret=1
# no WILDCARD proof (*.a.nsec3.example / TFGQ60S97BS31IT1EBEDO63ETM0T5JFA)
pat3="^R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\.nsec3\.example\..*NSEC3 1 0 0 - VH656EQUD4J02OFVSO4GKOK5D02MS1TL NS DS RRSIG\$"
grep "$pat3" dig.out.ns4.test$n >/dev/null || ret=1
n=$((n + 1))
if [ "$ret" -ne 0 ]; then echo_i "failed"; fi
status=$((status + ret))
echo_i "checking NSEC3 nxdomain response closest encloser with 1 ENTs ($n)"
ret=0
dig_with_opts @10.53.0.3 b.b.b.b.b.a.a.nsec3.example. >dig.out.ns3.test$n
grep "status: NXDOMAIN" dig.out.ns3.test$n >/dev/null || ret=1
pat="^NGCJFSOLJUUE27PFNQNJIME4TQ0OU2DH\.nsec3.example\..*NSEC3 1 0 0 - R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\$"
grep "$pat" dig.out.ns3.test$n >/dev/null || ret=1
dig_with_opts @10.53.0.4 b.b.b.b.b.a.a.nsec3.example. >dig.out.ns4.test$n
grep "status: NXDOMAIN" dig.out.ns4.test$n >/dev/null || ret=1
# closest encloser (a.a.nsec3.example)
pat1="^NGCJFSOLJUUE27PFNQNJIME4TQ0OU2DH\.nsec3\.example\..*NSEC3 1 0 0 - R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\$"
grep "$pat1" dig.out.ns4.test$n >/dev/null || ret=1
# no QNAME proof (b.a.a.nsec3.example / V8I8SAIIVC3HOVMOVENSDRA6ATDCEMJI)
pat2="^R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\.nsec3\.example\..*NSEC3 1 0 0 - VH656EQUD4J02OFVSO4GKOK5D02MS1TL NS DS RRSIG\$"
grep "$pat2" dig.out.ns4.test$n >/dev/null || ret=1
# no WILDCARD proof (*.a.a.nsec3.example / V7JNNDJ4NLRIU195FRB7DLUCSLU4LLFM)
pat3="^R8EVDMNIGNOKME4LH2H90OSP2PRSNJ1Q\.nsec3\.example\..*NSEC3 1 0 0 - VH656EQUD4J02OFVSO4GKOK5D02MS1TL NS DS RRSIG\$"
grep "$pat3" dig.out.ns4.test$n >/dev/null || ret=1
n=$((n + 1))
if [ "$ret" -ne 0 ]; then echo_i "failed"; fi
status=$((status + ret))
echo_i "checking NSEC3 nxdomain response closest encloser with 2 ENTs ($n)"
ret=0
dig_with_opts @10.53.0.3 b.b.b.b.b.a.a.a.nsec3.example. >dig.out.ns3.test$n
grep "status: NXDOMAIN" dig.out.ns3.test$n >/dev/null || ret=1
pat="^H7RHPDCHSVVRAND332F878C8AB6IBJQV\.nsec3.example\..*NSEC3 1 0 0 - K8IG76R2UPQ13IKFO49L7IB9JRVB6QJI\$"
grep "$pat" dig.out.ns3.test$n >/dev/null || ret=1
dig_with_opts @10.53.0.4 b.b.b.b.b.a.a.a.nsec3.example. >dig.out.ns4.test$n
grep "status: NXDOMAIN" dig.out.ns4.test$n >/dev/null || ret=1
# closest encloser (a.a.a.nsec3.example)
pat1="^H7RHPDCHSVVRAND332F878C8AB6IBJQV\.nsec3\.example\..*NSEC3 1 0 0 - K8IG76R2UPQ13IKFO49L7IB9JRVB6QJI\$"
grep "$pat1" dig.out.ns4.test$n >/dev/null || ret=1
# no QNAME proof (b.a.a.a.nsec3.example / 18Q8D89RM8GGRSSOPFRB05QS6VEGB1P4)
pat2="^VH656EQUD4J02OFVSO4GKOK5D02MS1TL\.nsec3\.example\..*NSEC3 1 0 0 - 1HARMGSKJH0EBU2EI2OJIKTDPIQA6KBI NS DS RRSIG\$"
grep "$pat2" dig.out.ns4.test$n >/dev/null || ret=1
# no WILDCARD proof (*.a.a.a.nsec3.example / 8113LDMSEFPUAG4VGFF1C8KLOUT4Q6PH)
pat3="^7AT0S0RIDCJRFF2M5H5AAV22CSFJBUL4\.nsec3\.example\..*NSEC3 1 0 0 - BEJ5GMQA872JF4DAGQ0R3O5Q7A2O5S9L A RRSIG\$"
grep "$pat3" dig.out.ns4.test$n >/dev/null || ret=1
n=$((n + 1))
if [ "$ret" -ne 0 ]; then echo_i "failed"; fi
status=$((status + ret))

View File

@@ -13,6 +13,7 @@ import pytest
pytestmark = pytest.mark.extra_artifacts(
[
".hypothesis/examples/*",
"K*",
"canonical*",
"delv.out*",

View File

@@ -13,7 +13,6 @@ from . import check
from . import instance
from . import query
from . import kasp
from . import name
from . import rndc
from . import run
from . import template

View File

@@ -11,7 +11,8 @@
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
from typing import List
import collections.abc
from typing import List, Union
from warnings import warn
from hypothesis.strategies import (
@@ -22,6 +23,7 @@ from hypothesis.strategies import (
just,
nothing,
permutations,
sampled_from,
)
import dns.name
@@ -37,7 +39,9 @@ def dns_names(
draw,
*,
prefix: dns.name.Name = dns.name.empty,
suffix: dns.name.Name = dns.name.root,
suffix: Union[
dns.name.Name, collections.abc.Iterable[dns.name.Name]
] = dns.name.root,
min_labels: int = 1,
max_labels: int = 128,
) -> dns.name.Name:
@@ -71,6 +75,14 @@ def dns_names(
"""
prefix = prefix.relativize(dns.name.root)
# Python str is iterable, but that's most probably not what user actually wanted
if isinstance(suffix, str):
raise NotImplementedError(
"ambiguous API use, convert suffix to Name or list to express intent"
)
if isinstance(suffix, collections.abc.Iterable):
suffix = draw(sampled_from(sorted(suffix)))
assert isinstance(suffix, dns.name.Name)
suffix = suffix.derelativize(dns.name.root)
try:

View File

@@ -9,12 +9,196 @@
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
import dns.name
from typing import Container, Iterable, FrozenSet
import pytest
pytest.importorskip("dns", minversion="2.1.0") # NameRelation
from dns.name import Name, NameRelation
import dns.zone
import dns.rdatatype
def prepend_label(label: str, name: dns.name.Name) -> dns.name.Name:
return dns.name.Name((label,) + name.labels)
def prepend_label(label: str, name: Name) -> Name:
return Name((label,) + name.labels)
def len_wire_uncompressed(name: dns.name.Name) -> int:
def len_wire_uncompressed(name: Name) -> int:
return len(name) + sum(map(len, name.labels))
def get_wildcard_names(names: Iterable[Name]) -> FrozenSet[Name]:
return frozenset(name for name in names if name.is_wild())
class ZoneAnalyzer:
"""
Categorize names in zone and provide list of ENTs:
- delegations - names with NS RR
- dnames - names with DNAME RR
- wildcards - names with leftmost label '*'
- reachable - non-empty authoritative nodes in zone
- have at least one auth RR set and are not occluded
- ents - reachable empty non-terminals
- occluded - names under a parent node which has DNAME or a non-apex NS
- reachable_delegations
- have NS RR on it, are not zone's apex, and are not occluded
- reachable_dnames - have DNAME RR on it and are not occluded
- reachable_wildcards - have leftmost label '*' and are not occluded
- reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped
Warnings:
- Quadratic complexity ahead! Use only on small test zones.
- Zone must be constant.
"""
@classmethod
def read_path(cls, zpath, origin):
with open(zpath, encoding="ascii") as zf:
zonedb = dns.zone.from_file(zf, origin, relativize=False)
return cls(zonedb)
def __init__(self, zone: dns.zone.Zone):
self.zone = zone
assert self.zone.origin # mypy hack
# based on individual nodes but not relationship between nodes
self.delegations = self.get_names_with_type(dns.rdatatype.NS) - {
self.zone.origin
}
self.dnames = self.get_names_with_type(dns.rdatatype.DNAME)
self.wildcards = get_wildcard_names(self.zone)
# takes relationship between nodes into account
self._categorize_names()
self.ents = self.generate_ents()
self.reachable_dnames = self.dnames.intersection(self.reachable)
self.reachable_wildcards = self.wildcards.intersection(self.reachable)
self.reachable_wildcard_parents = {
Name(wname[1:]) for wname in self.reachable_wildcards
}
# (except for wildcard expansions) all names in zone which result in NOERROR answers
self.all_existing_names = (
self.reachable.union(self.ents)
.union(self.reachable_delegations)
.union(self.reachable_dnames)
)
def get_names_with_type(self, rdtype) -> FrozenSet[Name]:
return frozenset(
name for name in self.zone if self.zone.get_rdataset(name, rdtype)
)
def _categorize_names(
self,
) -> None:
"""
Split names defined in a zone into three sets:
Generally reachable, reachable delegations, and occluded.
Delegations are set aside because they are a weird hybrid with different
rules for different RR types (NS, DS, NSEC, everything else).
"""
assert self.zone.origin # mypy workaround
reachable = set(self.zone)
# assume everything is reachable until proven otherwise
reachable_delegations = set(self.delegations)
occluded = set()
def _mark_occluded(name: Name) -> None:
occluded.add(name)
if name in reachable:
reachable.remove(name)
if name in reachable_delegations:
reachable_delegations.remove(name)
# sanity check, should be impossible with dnspython 2.7.0 zone reader
for name in reachable:
relation, _, _ = name.fullcompare(self.zone.origin)
if relation in (
NameRelation.NONE, # out of zone?
NameRelation.SUPERDOMAIN, # parent of apex?!
):
raise NotImplementedError
for maybe_occluded in reachable.copy():
for cut in self.delegations:
rel, _, _ = maybe_occluded.fullcompare(cut)
if rel == NameRelation.EQUAL:
# data _on_ a parent-side of a zone cut are in limbo:
# - are not really authoritative (except for DS)
# - but NS is not really 'occluded'
# We remove them from 'reachable' but do not add them to 'occluded' set,
# i.e. leave them in 'reachable_delegations'.
if maybe_occluded in reachable:
reachable.remove(maybe_occluded)
if rel == NameRelation.SUBDOMAIN:
_mark_occluded(maybe_occluded)
# do not break cycle - handle also nested NS and DNAME
# DNAME itself is authoritative but nothing under it is reachable
for dname in self.dnames:
rel, _, _ = maybe_occluded.fullcompare(dname)
if rel == NameRelation.SUBDOMAIN:
_mark_occluded(maybe_occluded)
# do not break cycle - handle also nested NS and DNAME
self.reachable = frozenset(reachable)
self.reachable_delegations = frozenset(reachable_delegations)
self.occluded = frozenset(occluded)
def generate_ents(self) -> FrozenSet[Name]:
"""
Generate reachable names of empty nodes "between" all reachable
names with a RR and the origin.
"""
assert self.zone.origin
all_reachable_names = self.reachable.union(self.reachable_delegations)
ents = set()
for name in all_reachable_names:
_, super_name = name.split(len(name) - 1)
while len(super_name) > len(self.zone.origin):
if super_name not in all_reachable_names:
ents.add(super_name)
_, super_name = super_name.split(len(super_name) - 1)
return frozenset(ents)
def closest_encloser(self, qname: Name):
"""
Get (closest encloser, next closer name) for given qname.
"""
ce = None # Closest encloser, RFC 4592
nce = None # Next closer name, RFC 5155
for zname in self.all_existing_names:
relation, _, common_labels = qname.fullcompare(zname)
if relation == NameRelation.SUBDOMAIN:
if not ce or common_labels > len(ce):
# longest match so far
ce = zname
_, nce = qname.split(len(ce) + 1)
assert ce is not None
assert nce is not None
return ce, nce
def source_of_synthesis(self, qname: Name) -> Name:
"""
Return source of synthesis according to RFC 4592 section 3.3.1.
Name is not guaranteed to exist or be reachable.
"""
ce, _ = self.closest_encloser(qname)
return Name("*") + ce
def is_related_to_any(
test_name: Name,
acceptable_relations: Container[NameRelation],
candidates: Iterable[Name],
) -> bool:
for maybe_parent in candidates:
relation, _, _ = test_name.fullcompare(maybe_parent)
if relation in acceptable_relations:
return True
return False

View File

@@ -0,0 +1,31 @@
/*
* 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.
*/
// NS1
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;
};
zone "." {
type primary;
file "root.db.signed";
};

View File

@@ -0,0 +1,51 @@
; 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.
$TTL 300
. IN SOA . . (
2025063000 ; serial
600 ; refresh
600 ; retry
1200 ; expire
600 ; minimum
)
. NS a.root-servers.nil.
02hc3em7bdd011a0gms3hkkjt2if5vp8. A 10.0.0.0
a. A 10.0.0.1
*.a.a. A 10.0.0.6
a.a.a.a. A 10.0.0.3
b. A 10.0.0.2
b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b.b. A 10.0.0.2
cname. CNAME does-not-exist.
cname.cname. CNAME cname.
cname.ent.cname. CNAME cname.cname.
d. A 10.0.0.4
dname-to-nowhere. DNAME does-not-exist.
; DNAME owner longer than target to avoid YXDOMAIN dependent on QNAME
insecure. NS a.root-servers.nil.
ns.insecure. A 10.53.0.3
a.root-servers.nil. A 10.53.0.1
secure. NS a.root-servers.nil.
secure. DS 11111 13 255 00
occluded.secure. A 0.0.0.0
*.wild. A 10.0.0.6
explicit.wild. A 192.0.2.66
z. A 10.0.0.26
; randomly generated subtree to excercise unknown corner cases
; intentionally small, to not blow up algorithms with quadratic complexity in ZoneAnalyzer and name generator
a.a.a.b.a.a.a.b.a.a.b.b.a.random. TXT "r"
b.b.a.a.b.b.a.a.a.b.b.a.b.a.a.a.a.a.b.a.a.b.a.b.a.b.b.b.b.b.a.a.a.a.b.a.a.a.b.a.a.b.b.a.random. TXT "r"
a.a.a.b.b.a.b.b.a.b.a.b.a.b.a.b.b.b.a.random. TXT "r"
b.b.a.b.a.b.a.a.a.b.a.a.b.a.a.a.a.b.b.a.b.b.a.b.a.b.a.b.a.b.b.b.a.random. TXT "r"
a.b.a.a.b.a.b.a.b.a.a.b.a.b.a.a.a.b.b.a.b.b.a.a.b.b.a.a.b.a.b.a.b.b.b.b.a.a.a.a.a.a.a.a.b.a.b.a.b.b.a.b.a.b.a.a.a.b.a.a.b.a.a.a.a.b.b.a.b.b.a.b.a.b.a.b.a.b.b.b.a.random. TXT "r"
a.a.a.a.a.b.b.a.a.a.a.a.b.b.a.a.b.a.a.b.a.a.b.b.a.a.a.b.a.a.a.b.b.b.b.b.a.a.a.b.b.b.b.b.b.a.b.b.b.a.a.b.b.b.b.a.a.a.a.b.a.b.b.a.b.a.a.b.b.b.b.b.b.b.a.b.b.a.b.a.b.a.a.a.b.b.a.a.b.b.a.b.a.b.b.a.b.b.b.a.b.b.b.b.b.a.a.b.a.a.a.b.b.a.a.a.b.b.b.b.b.a.random. TXT "r"

View File

@@ -0,0 +1,34 @@
#!/bin/sh -e
# 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.
# shellcheck source=conf.sh
. ../../conf.sh
set -e
zone=.
infile=root.db.in
zonefile=root.db
echo_i "ns1/sign.sh"
ksk=$("$KEYGEN" -q -fk -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone")
zsk=$("$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -b "$DEFAULT_BITS" "$zone")
cat "$infile" "$ksk.key" "$zsk.key" >"$zonefile"
SALT="$(printf "%04x" "$(($(date +%s) / 3600 % 65536))")"
echo_ic "NSEC3 salt for this hour: $SALT"
"$SIGNER" -3 "$SALT" -o "$zone" "$zonefile" 2>&1 >"$zonefile.sign.log"
keyfile_to_initial_ds "$ksk" >managed-keys.conf

View File

@@ -0,0 +1,39 @@
/*
* 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.
*/
// validating resolver
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 yes;
dnssec-validation yes;
};
controls {
inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};
include "../../_common/rndc.key";
zone "." {
type hint;
file "../../_common/root.hint";
};
include "../ns1/managed-keys.conf";

View File

@@ -0,0 +1,22 @@
#!/bin/sh -e
# 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.
# shellcheck source=conf.sh
. ../conf.sh
set -e
(
cd ns1
$SHELL sign.sh
)

View File

@@ -0,0 +1,410 @@
#!/usr/bin/python3
# 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
import os
from pathlib import Path
from typing import Optional, Set, Tuple
import pytest
pytest.importorskip("dns", minversion="2.5.0")
import dns.dnssec
import dns.message
import dns.name
import dns.query
import dns.rcode
import dns.rdataclass
import dns.rdatatype
import dns.rdtypes.ANY.RRSIG
import dns.rdtypes.ANY.NSEC3
import dns.rrset
from isctest.hypothesis.strategies import dns_names, sampled_from
import isctest
import isctest.name
from hypothesis import assume, given
SUFFIX = dns.name.from_text(".")
AUTH = "10.53.0.1"
RESOLVER = "10.53.0.2"
TIMEOUT = 5
ZONE = isctest.name.ZoneAnalyzer.read_path(
Path(os.environ["srcdir"]) / "nsec3-answer/ns1/root.db.in", origin=SUFFIX
)
def do_test_query(
qname: dns.name.Name, qtype: dns.rdatatype.RdataType, server: str, named_port: int
) -> Tuple[dns.message.QueryMessage, "NSEC3Checker"]:
query = dns.message.make_query(qname, qtype, use_edns=True, want_dnssec=True)
response = isctest.query.tcp(query, server, named_port, timeout=TIMEOUT)
isctest.check.is_response_to(response, query)
assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN)
return response, NSEC3Checker(response)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(
qname=sampled_from(
sorted(ZONE.reachable - ZONE.get_names_with_type(dns.rdatatype.CNAME))
)
)
def test_nodata(server: str, qname: dns.name.Name, named_port: int) -> None:
"""An existing name, no wildcards, but a query type for RRset which does not exist"""
_, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port)
check_nodata(qname, nsec3check)
@pytest.mark.parametrize("server", [pytest.param(AUTH, id="ns1")])
@given(
qname=dns_names(
suffix=(ZONE.delegations - ZONE.get_names_with_type(dns.rdatatype.DS))
)
)
def test_nodata_ds(server: str, qname: dns.name.Name, named_port: int) -> None:
"""Auth sends proof of nonexistance with referral without DS RR. Opt-out is not supported."""
response, nsec3check = do_test_query(qname, dns.rdatatype.HINFO, server, named_port)
nsrr = None
for rrset in response.authority:
if rrset.rdtype == dns.rdatatype.NS:
nsrr = rrset
break
assert nsrr is not None, "NS RRset missing in delegation answer"
# DS RR does not exist so we must prove it by having NSEC3 with QNAME
check_nodata(nsrr.name, nsec3check)
def check_nodata(name: dns.name.Name, nsec3check: "NSEC3Checker") -> None:
assert nsec3check.response.rcode() is dns.rcode.NOERROR
nsec3check.prove_name_exists(name)
nsec3check.check_extraneous_rrs()
def assume_nx_and_no_delegation(qname: dns.name.Name) -> None:
assume(qname not in ZONE.all_existing_names)
# name must not be under a delegation or DNAME:
# it would not work with resolver ns2
assume(
not isctest.name.is_related_to_any(
qname,
(dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN),
ZONE.reachable_delegations.union(ZONE.reachable_dnames),
)
)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(qname=dns_names(suffix=SUFFIX))
def test_nxdomain(server: str, qname: dns.name.Name, named_port: int) -> None:
"""A real NXDOMAIN, no wildcards involved"""
assume_nx_and_no_delegation(qname)
wname = ZONE.source_of_synthesis(qname)
assume(wname not in ZONE.reachable_wildcards)
_, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port)
check_nxdomain(qname, nsec3check)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(qname=sampled_from(sorted(ZONE.get_names_with_type(dns.rdatatype.CNAME))))
def test_cname_nxdomain(server: str, qname: dns.name.Name, named_port: int) -> None:
"""CNAME which terminates by NXDOMAIN, no wildcards involved"""
response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port)
chain = response.resolve_chaining()
assume_nx_and_no_delegation(chain.canonical_name)
wname = ZONE.source_of_synthesis(chain.canonical_name)
assume(wname not in ZONE.reachable_wildcards)
check_nxdomain(chain.canonical_name, nsec3check)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(qname=dns_names(suffix=ZONE.get_names_with_type(dns.rdatatype.DNAME)))
def test_dname_nxdomain(server: str, qname: dns.name.Name, named_port: int) -> None:
"""DNAME which terminates by NXDOMAIN, no wildcards involved"""
assume(qname not in ZONE.reachable)
response, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port)
chain = response.resolve_chaining()
assume_nx_and_no_delegation(chain.canonical_name)
wname = ZONE.source_of_synthesis(chain.canonical_name)
assume(wname not in ZONE.reachable_wildcards)
check_nxdomain(chain.canonical_name, nsec3check)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(qname=dns_names(suffix=ZONE.ents))
def test_ents(server: str, qname: dns.name.Name, named_port: int) -> None:
"""ENT can have a wildcard under it"""
assume_nx_and_no_delegation(qname)
_, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port)
wname = ZONE.source_of_synthesis(qname)
# does qname match a wildcard under ENT?
if wname in ZONE.reachable_wildcards:
check_wildcard_synthesis(qname, nsec3check)
else:
check_nxdomain(qname, nsec3check)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents))
def test_wildcard_synthesis(server: str, qname: dns.name.Name, named_port: int) -> None:
assume(qname not in ZONE.all_existing_names)
wname = ZONE.source_of_synthesis(qname)
assume(wname in ZONE.reachable_wildcards)
_, nsec3check = do_test_query(qname, dns.rdatatype.A, server, named_port)
check_wildcard_synthesis(qname, nsec3check)
@pytest.mark.parametrize(
"server", [pytest.param(AUTH, id="ns1"), pytest.param(RESOLVER, id="ns2")]
)
@given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents))
def test_wildcard_nodata(server: str, qname: dns.name.Name, named_port: int) -> None:
assume(qname not in ZONE.all_existing_names)
wname = ZONE.source_of_synthesis(qname)
assume(wname in ZONE.reachable_wildcards)
_, nsec3check = do_test_query(qname, dns.rdatatype.AAAA, server, named_port)
check_wildcard_nodata(qname, nsec3check)
def check_wildcard_nodata(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None:
assert nsec3check.response.rcode() is dns.rcode.NOERROR
ce, nce = ZONE.closest_encloser(qname)
nsec3check.prove_name_exists(ce)
nsec3check.prove_name_does_not_exist(nce)
wname = ZONE.source_of_synthesis(qname)
# expecting proof that wildcard owner does not have rdatatype requested
nsec3check.prove_name_exists(wname)
nsec3check.check_extraneous_rrs()
def check_nxdomain(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None:
assert nsec3check.response.rcode() is dns.rcode.NXDOMAIN
ce, nce = ZONE.closest_encloser(qname)
nsec3check.prove_name_exists(ce)
nsec3check.prove_name_does_not_exist(nce)
wname = ZONE.source_of_synthesis(qname)
nsec3check.prove_name_does_not_exist(wname)
nsec3check.check_extraneous_rrs()
def check_wildcard_synthesis(qname: dns.name.Name, nsec3check: "NSEC3Checker") -> None:
"""Expect wildcard response with a signed A RRset"""
assert nsec3check.response.rcode() is dns.rcode.NOERROR
answer_sig = nsec3check.response.get_rrset(
section="ANSWER",
name=qname,
rdclass=dns.rdataclass.IN,
rdtype=dns.rdatatype.RRSIG,
covers=dns.rdatatype.A,
)
assert answer_sig is not None
assert len(answer_sig) == 1
rrsig = answer_sig[0]
assert isinstance(rrsig, dns.rdtypes.ANY.RRSIG.RRSIG)
# RRSIG labels field RFC 4034 section 3.1.3 does not count:
# - root label
# - leftmost * label
wildcard_parent_labels = rrsig.labels + 1 # add root but not leftmost *
assert wildcard_parent_labels < len(qname)
# 1. We have RRSIG from the wildcard '*.something', which proves the node
# 'something' exists (by definition - it has a child, so it exists, but
# maybe it is an ENT). Thus we expect closest encloser = 'something'
# 2. If wildcard synthesis is legitimate, QNAME itself and no nodes between
# QNAME and the closest encloser can exist. Because of DNS node existence
# rules it's sufficient to prove non-existence of next-closer name, i.e.
# <one_label_under>.<closest_encloser>, to deny existence of the whole
# subtree down to QNAME.
ce, nce = ZONE.closest_encloser(qname)
assert ce == qname.split(wildcard_parent_labels)[1]
# ce is proven to exist by the RRSIG
assert nce == qname.split(wildcard_parent_labels + 1)[1]
nsec3check.prove_name_does_not_exist(nce)
nsec3check.check_extraneous_rrs()
@dataclass(kw_only=True, frozen=True)
class NSEC3Params:
"""Common values from a single DNS response"""
algorithm: int
flags: int
iterations: int
salt: Optional[bytes]
class NSEC3Checker:
def __init__(self, response: dns.message.Message):
for rrset in response.answer:
assert not rrset.match(
dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
), f"unexpected NSEC3 RR in ANSWER section:\n{response}"
for rrset in response.additional:
assert not rrset.match(
dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
), f"unexpected NSEC3 RR in ADDITIONAL section:\n{response}"
attrs_seen = {
"algorithm": None,
"flags": None,
"iterations": None,
"salt": None,
}
first = True
owners_seen = set()
self.rrsets = []
for rrset in response.authority:
if not rrset.match(
dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
):
continue
assert (
rrset.name not in owners_seen
), f"duplicate NSEC3 owner {rrset.name}:\n{response}"
owners_seen.add(rrset.name)
assert len(rrset) == 1
rr = rrset[0]
assert isinstance(rr, dns.rdtypes.ANY.NSEC3.NSEC3)
assert (
"NSEC3"
not in dns.rdtypes.ANY.NSEC3.Bitmap(rr.windows).to_text().split()
), f"NSEC3 RRset with NSEC3 in type bitmap:\n{response}"
# NSEC3 parameters MUST be consistent across all NSEC3 RRs:
# RFC 5155 section 7.2, last paragraph
for attr_name, value_seen in attrs_seen.items():
current = getattr(rr, attr_name)
if first:
attrs_seen[attr_name] = current
else:
assert (
current == value_seen
), f"inconsistent {attr_name}\n{response}"
first = False
self.rrsets.append(rrset)
assert attrs_seen["algorithm"] is not None, f"no NSEC3 found\n{response}"
self.params: NSEC3Params = NSEC3Params(**attrs_seen)
self.response: dns.message.Message = response
self.owners_present: Set[dns.name.Name] = owners_seen
self.owners_used: Set[dns.name.Name] = set()
@staticmethod
def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool:
"""
Test if 'hashed_name' is covered by an NSEC3 record in 'rrset', i.e. the name does not exist.
"""
prev_name = rrset.name
assert len(rrset) == 1
nsec3 = rrset[0]
assert isinstance(nsec3, dns.rdtypes.ANY.NSEC3.NSEC3)
assert nsec3.flags == 0, "opt-out not supported by test logic"
next_name = nsec3.next_name(SUFFIX)
# Single name case.
if prev_name == next_name:
return prev_name != hashed_name
# Standard case.
if prev_name < next_name:
if prev_name < hashed_name < next_name:
return True
# The cover wraps.
if next_name < prev_name:
# Case 1: The covered name is at the end of the chain.
if hashed_name > prev_name:
return True
# Case 2: The covered name is at the start of the chain.
if hashed_name < next_name:
return True
return False
def hash_name(self, name: dns.name.Name) -> dns.name.Name:
nhash = dns.dnssec.nsec3_hash(
name,
salt=self.params.salt,
iterations=self.params.iterations,
algorithm=self.params.algorithm,
)
return dns.name.from_text(nhash, SUFFIX)
def prove_name_does_not_exist(self, name: dns.name.Name) -> dns.rrset.RRset:
"""Hash of a given name must fall between an NSEC3 owner and 'next' name"""
hashed_name = self.hash_name(name)
for rrset in self.rrsets:
name_is_covered = self.nsec3_covers(rrset, hashed_name)
if name_is_covered:
self.owners_used.add(rrset.name)
return rrset
assert (
False
), f"Expected covering NSEC3 for {name} (hash={hashed_name}) not found:\n{self.response}"
def prove_name_exists(self, owner: dns.name.Name) -> dns.rrset.RRset:
"""Check response has NSEC3 RR matching given owner name, i.e. the name exists."""
nsec3_owner = self.hash_name(owner)
for rrset in self.rrsets:
if rrset.match(
nsec3_owner, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
):
self.owners_used.add(rrset.name)
return rrset
assert (
False
), f"Expected matching NSEC3 for {owner} (hash={nsec3_owner}) not found:\n{self.response}"
def check_extraneous_rrs(self) -> None:
"""Check that all NSEC3 RRs present in the message were actually needed for proofs"""
assert (
self.owners_used == self.owners_present
), f"extraneous NSEC3 RRs detected\n{self.response}"

View File

@@ -0,0 +1,228 @@
#!/usr/bin/python3
# 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.
"""
isctest.name.ZoneAnalyzer self-test
Generate insane test zone and check expected output of ZoneAnalyzer utility class
"""
import collections
import itertools
from pathlib import Path
import dns.name
from dns.name import Name
import pytest
import isctest
import isctest.name
# set of properies present in the tested zone - read by tests_zone_analyzer.py
CATEGORIES = frozenset(
[
"all_existing_names",
"delegations",
"dnames",
"ents",
"occluded",
"reachable",
"reachable_delegations",
"reachable_dnames",
"reachable_wildcards",
"reachable_wildcard_parents",
"wildcards",
]
)
pytestmark = pytest.mark.extra_artifacts(["analyzer.db"])
SUFFIX = dns.name.from_text("nsec3.example.")
LABELS = (b"*", b"dname", b"ent", b"ns", b"txt")
LABEL2RRTYPE = { # leftmost label encodes RR type we will synthesize for given name
b"*": "TXT",
b"dname": "DNAME",
b"ent": None, # ENT is not really a 'type'
b"ns": "NS",
b"txt": "TXT",
}
LABEL2TAGS = { # leftmost label encodes 'initial' meaning of a complete name
b"*": {"wildcards"},
b"dname": {"dnames"},
b"ns": {"delegations"},
b"txt": set(), # perhaps reachable, perhaps not, we need to decide based on other labels
}
def name2tags(name):
"""
Decode meaning hidden in labels and their relationships
and return all tags expected from ZoneAnalyzer
"""
tags = LABEL2TAGS[name[0]].copy()
parent_labels = name[1:]
if b"ns" in parent_labels or b"dname" in parent_labels:
tags.add("occluded")
if "occluded" not in tags:
tags.add("all_existing_names")
if "delegations" in tags:
# delegations are ambiguous and don't count as 'reachable'
tags.add("reachable_delegations")
elif "dnames" in tags:
tags.add("reachable")
tags.add("reachable_dnames")
elif "wildcards" in tags:
tags.add("reachable")
tags.add("reachable_wildcards")
else:
tags.add("reachable")
return tags
def gen_node(nodes, labels):
name = Name(labels)
nodes[name] = name2tags(name)
def add_ents(nodes):
"""
Non-occluded nodes with 'ent' as a parent label imply existence of 'ent' nodes.
"""
new_ents = {}
for name, tags in nodes.items():
if "occluded" in tags:
continue
# check if any parent is ENT
entidx = 1
while True:
try:
entidx = name.labels.index(b"ent", entidx)
except ValueError:
break
entname = Name(name[entidx:])
new_ents[entname] = {"all_existing_names", "ents"}
entidx += 1
return new_ents
def tag_wildcard_parents(nodes):
"""
Non-occluded nodes with '*' as a leftmost label tag their immediate parent
nodes as 'reachable_wildcard_parents'.
"""
for name, tags in nodes.items():
if "occluded" in tags or not name.is_wild():
continue
parent_name = Name(name[1:])
nodes[parent_name].add("reachable_wildcard_parents")
def is_non_ent(labels):
"""
Filter out nodes with 'ent' at leftmost position. To become ENT a name must
not have data by itself but have some other node defined underneath it,
and must not be occluded, which is something itertools.product() cannot
decide.
"""
return labels[0] != b"ent"
def gen_zone(nodes):
"""
Generate zone file in text format.
All names are relative.
Right-hand side of RRs contains dot-separated list of categories a node
belongs to (except for zone origin).
"""
for name, tags in sorted(nodes.items()):
if len(name) == 0:
# origin, very special case
yield "@\tSOA\treachable. origin-special-case. 0 0 0 0 0\n"
yield "@\tNS\treachable.\n"
yield "@\tA\t192.0.2.1\n"
continue
rrtype = LABEL2RRTYPE[name[0]]
if rrtype is None: # ENT
prefix = "; "
else:
prefix = ""
assert tags
yield f"{prefix}{name}\t{rrtype}\t{'.'.join(sorted(tags))}.\n"
def gen_expected_output(nodes):
"""
{category: set(names)} mapping used by the pytest check
"""
categories = collections.defaultdict(set)
for name, tags in nodes.items():
for tag in tags:
categories[tag].add(name)
assert set(categories.keys()) == CATEGORIES, (
"CATEGORIES needs updating",
CATEGORIES.symmetric_difference(set(categories.keys())),
)
return categories
def generate_test_data():
"""
Prepare the analyzer.db zone file in the current working directory and
return the expected attribute values for the ZoneAnalyzer instance that
will be tested using that file.
"""
nodes = {}
for length in range(1, len(LABELS) + 1):
for labelseq in filter(is_non_ent, itertools.product(LABELS, repeat=length)):
gen_node(nodes, labelseq)
# special-case to make this look as a valid DNS zone - it needs zone origin node
nodes[Name([])] = {"all_existing_names", "reachable"}
nodes.update(add_ents(nodes))
tag_wildcard_parents(nodes)
with open("analyzer.db", "w", encoding="ascii") as outf:
outf.writelines(gen_zone(nodes))
return gen_expected_output(nodes)
if __name__ == "__main__":
generate_test_data()
@pytest.fixture(scope="module")
def analyzer_fixture():
expected_results = generate_test_data() # creates the "analyzer.db" file
analyzer = isctest.name.ZoneAnalyzer.read_path(Path("analyzer.db"), origin=SUFFIX)
return expected_results, analyzer
# pylint: disable=redefined-outer-name
@pytest.mark.parametrize("category", sorted(CATEGORIES))
def test_analyzer_attrs(category, analyzer_fixture):
expected_results, analyzer = analyzer_fixture
# relativize results to zone name to make debugging easier
results = {name.relativize(SUFFIX) for name in getattr(analyzer, category)}
assert results == expected_results[category]

View File

@@ -15,6 +15,12 @@ import subprocess
import pytest
import isctest
from isctest.hypothesis.strategies import dns_names
from hypothesis import strategies, given, settings
from dns.dnssectypes import NSEC3Hash
import dns.dnssec
NSEC3HASH = os.environ.get("NSEC3HASH")
@@ -120,3 +126,51 @@ def test_nsec3_missing_args(args):
def test_nsec3_bad_option():
with pytest.raises(subprocess.CalledProcessError):
isctest.run.cmd([NSEC3HASH, "-?"])
@given(
domain=dns_names(),
it=strategies.integers(min_value=0, max_value=65535),
salt_bytes=strategies.binary(min_size=0, max_size=255),
)
def test_nsec3hash_acceptable_values(domain, it, salt_bytes) -> None:
if not salt_bytes:
salt_text = "-"
else:
salt_text = salt_bytes.hex()
# calculate the hash using dnspython:
hash1 = dns.dnssec.nsec3_hash(
domain, salt=salt_bytes, iterations=it, algorithm=NSEC3Hash.SHA1
)
# calculate the hash using nsec3hash:
output = isctest.run.cmd(
[NSEC3HASH, salt_text, "1", str(it), str(domain)]
).stdout.decode("ascii")
hash2 = output.partition(" ")[0]
assert hash1 == hash2
@settings(max_examples=5)
@given(
domain=dns_names(),
it=strategies.integers(min_value=0, max_value=65535),
salt_bytes=strategies.binary(min_size=256),
)
def test_nsec3hash_salt_too_long(domain, it, salt_bytes) -> None:
salt_text = salt_bytes.hex()
with pytest.raises(subprocess.CalledProcessError):
isctest.run.cmd([NSEC3HASH, salt_text, "1", str(it), str(domain)])
@settings(max_examples=5)
@given(
domain=dns_names(),
it=strategies.integers(min_value=65536),
salt_bytes=strategies.binary(min_size=0, max_size=255),
)
def test_nsec3hash_too_many_iterations(domain, it, salt_bytes) -> None:
salt_text = salt_bytes.hex()
with pytest.raises(subprocess.CalledProcessError):
isctest.run.cmd([NSEC3HASH, salt_text, "1", str(it), str(domain)])