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:
@@ -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))
|
||||
|
@@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
pytestmark = pytest.mark.extra_artifacts(
|
||||
[
|
||||
".hypothesis/examples/*",
|
||||
"K*",
|
||||
"canonical*",
|
||||
"delv.out*",
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
31
bin/tests/system/nsec3-answer/ns1/named.conf.j2
Normal file
31
bin/tests/system/nsec3-answer/ns1/named.conf.j2
Normal 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";
|
||||
};
|
51
bin/tests/system/nsec3-answer/ns1/root.db.in
Normal file
51
bin/tests/system/nsec3-answer/ns1/root.db.in
Normal 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"
|
34
bin/tests/system/nsec3-answer/ns1/sign.sh
Normal file
34
bin/tests/system/nsec3-answer/ns1/sign.sh
Normal 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
|
39
bin/tests/system/nsec3-answer/ns2/named.conf.j2
Normal file
39
bin/tests/system/nsec3-answer/ns2/named.conf.j2
Normal 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";
|
22
bin/tests/system/nsec3-answer/setup.sh
Normal file
22
bin/tests/system/nsec3-answer/setup.sh
Normal 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
|
||||
)
|
410
bin/tests/system/nsec3-answer/tests_nsec3.py
Executable file
410
bin/tests/system/nsec3-answer/tests_nsec3.py
Executable 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}"
|
228
bin/tests/system/selftest/tests_zone_analyzer.py
Executable file
228
bin/tests/system/selftest/tests_zone_analyzer.py
Executable 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]
|
@@ -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)])
|
||||
|
Reference in New Issue
Block a user