Rewrite nsec3 system test to pytest (1/4)

This converts all the nsec3 system test cases prior to reconfiguring the
name server. There are two main classes, one that tests the zone is
correctly signed with NSEC, the other with NSEC3.

Two extra tests for nsec3-dynamic-update-inline.kasp and
nsec3-change.kasp are also rewritten. For the former, we need to
change the 'nsupdate' definition to be able to set the expected RCODE.

(cherry picked from commit e81cc1520a)
This commit is contained in:
Matthijs Mekking 2025-09-30 09:13:38 +02:00 committed by Matthijs Mekking (GitLab job 6509612)
parent bb9451c73f
commit e8457b1358
3 changed files with 450 additions and 156 deletions

View file

@ -154,7 +154,9 @@ class NamedInstance:
return response
def nsupdate(self, update_msg: dns.message.Message):
def nsupdate(
self, update_msg: dns.message.Message, expected_rcode=dns.rcode.NOERROR
):
"""
Issue a dynamic update to a server's zone.
"""
@ -168,12 +170,14 @@ class NamedInstance:
self.ip,
self.ports.dns,
timeout=3,
expected_rcode=dns.rcode.NOERROR,
expected_rcode=expected_rcode,
)
except dns.exception.Timeout as exc:
msg = f"update timeout for {zone}"
raise dns.exception.Timeout(msg) from exc
debug(f"update of zone {zone} to server {self.ip} successful")
debug(
f"update of zone {zone} to server {self.ip} finished with {expected_rcode}"
)
return response
def watch_log_from_start(

View file

@ -235,159 +235,6 @@ key_clear "KEY2"
key_clear "KEY3"
key_clear "KEY4"
# Zone: nsec-to-nsec3.kasp.
set_zone_policy "nsec-to-nsec3.kasp" "nsec" 1 3600
set_server "ns3" "10.53.0.3"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec
if [ $RSASHA1_SUPPORTED = 1 ]; then
# Zone: rsasha1-to-nsec3.kasp.
set_zone_policy "rsasha1-to-nsec3.kasp" "rsasha1" 1 3600
set_server "ns3" "10.53.0.3"
set_key_rsasha1_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec
# Zone: rsasha1-to-nsec3-wait.kasp.
set_zone_policy "rsasha1-to-nsec3-wait.kasp" "rsasha1" 1 3600
set_server "ns3" "10.53.0.3"
set_key_rsasha1_values "KEY1"
set_key_states "KEY1" "omnipresent" "omnipresent" "omnipresent" "omnipresent" "omnipresent"
echo_i "initial check zone ${ZONE}"
check_nsec
# Zone: nsec3-to-rsasha1.kasp.
set_zone_policy "nsec3-to-rsasha1.kasp" "nsec3" 1 3600
set_server "ns3" "10.53.0.3"
set_key_rsasha1_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-to-rsasha1-ds.kasp.
set_zone_policy "nsec3-to-rsasha1-ds.kasp" "nsec3" 1 3600
set_server "ns3" "10.53.0.3"
set_key_rsasha1_values "KEY1"
set_key_states "KEY1" "omnipresent" "omnipresent" "omnipresent" "omnipresent" "omnipresent"
echo_i "initial check zone ${ZONE}"
check_nsec3
fi
# Zone: nsec3.kasp.
set_zone_policy "nsec3.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-dynamic.kasp.
set_zone_policy "nsec3-dynamic.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-change.kasp.
set_zone_policy "nsec3-change.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Test that NSEC3PARAM TTL is equal to SOA MINIMUM.
n=$((n + 1))
echo_i "check TTL of NSEC3PARAM in zone $ZONE is equal to SOA MINIMUM ($n)"
ret=0
dig_with_opts +noquestion "@${SERVER}" "$ZONE" NSEC3PARAM >"dig.out.test$n" || ret=1
grep "${ZONE}\..*3600.*IN.*NSEC3PARAM" "dig.out.test$n" >/dev/null || ret=1
test "$ret" -eq 0 || echo_i "failed"
status=$((status + ret))
# Update SOA MINIMUM.
cp "${DIR}/template2.db.in" "${DIR}/${ZONE}.db"
rndccmd $SERVER reload $ZONE >rndc.reload.test$n.$ZONE || log_error "failed to call rndc reload $ZONE"
_wait_for_new_soa() {
dig_with_opts +noquestion "@${SERVER}" "$ZONE" SOA >"dig.out.soa.test$n" || return 1
grep "${ZONE}\..*IN.*SOA.*mname1..*..*20.*20.*.1814400.*900" "dig.out.soa.test$n" >/dev/null || return 1
}
retry_quiet 10 _wait_for_new_soa || log_error "failed to update SOA record in zone $ZONE"
# Zone: nsec3-dynamic-change.kasp.
set_zone_policy "nsec3-dynamic-change.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-dynamic-to-inline.kasp.
set_zone_policy "nsec3-dynamic-to-inline.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-inline-to-dynamic.kasp.
set_zone_policy "nsec3-inline-to-dynamic.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-to-nsec.kasp.
set_zone_policy "nsec3-to-nsec.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-to-optout.kasp.
set_zone_policy "nsec3-to-optout.kasp" "nsec3" 1 3600
set_nsec3param "0" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-from-optout.kasp.
set_zone_policy "nsec3-from-optout.kasp" "optout" 1 3600
set_nsec3param "1" "0"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-other.kasp.
set_zone_policy "nsec3-other.kasp" "nsec3-other" 1 3600
set_nsec3param "1" "8"
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec3
# Zone: nsec3-xfr-inline.kasp.
# This is a secondary zone, where the primary is signed with NSEC3 but
# the dnssec-policy dictates NSEC.
set_zone_policy "nsec3-xfr-inline.kasp" "nsec" 1 3600
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec
# Zone: nsec3-dynamic-update-inline.kasp.
set_zone_policy "nsec3-dynamic-update-inline.kasp" "nsec" 1 3600
set_key_default_values "KEY1"
echo_i "initial check zone ${ZONE}"
check_nsec
n=$((n + 1))
echo_i "dynamic update dnssec-policy zone ${ZONE} with NSEC3 ($n)"
ret=0
$NSUPDATE >update.out.$ZONE.test$n 2>&1 <<END || ret=1
server 10.53.0.3 ${PORT}
zone ${ZONE}.
update add 04O18462RI5903H8RDVL0QDT5B528DUJ.${ZONE}. 3600 NSEC3 0 0 0 408A4B2D412A4E95 1JMDDPMTFF8QQLIOINSIG4CR9OTICAOC A RRSIG
send
END
wait_for_log 10 "updating zone '${ZONE}/IN': update failed: explicit NSEC3 updates are not allowed in secure zones (REFUSED)" ns3/named.run || ret=1
check_nsec
# Reconfig named.
ret=0
echo_i "reconfig dnssec-policy to trigger nsec3 rollovers"

View file

@ -0,0 +1,443 @@
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
import shutil
import os
from datetime import timedelta
import dns
import dns.update
import pytest
pytest.importorskip("dns", minversion="2.0.0")
import isctest
import isctest.mark
from isctest.vars.algorithms import RSASHA1
pytestmark = pytest.mark.extra_artifacts(
[
"*.axfr",
"*.created",
"dig.out.*",
"rndc.reload.*",
"rndc.signing.*",
"update.out.*",
"verify.out.*",
"ns*/dsset-**",
"ns*/K*",
"ns*/settime.out.*",
"ns*/*.db",
"ns*/*.jbk",
"ns*/*.jnl",
"ns*/*.signed",
"ns*/keygen.out.*",
"ns3/named-fips.conf",
]
)
ALGORITHM = os.environ["DEFAULT_ALGORITHM_NUMBER"]
SIZE = os.environ["DEFAULT_BITS"]
default_config = {
"dnskey-ttl": timedelta(hours=1),
"ds-ttl": timedelta(days=1),
"key-directory": "{keydir}",
"max-zone-ttl": timedelta(days=1),
"parent-propagation-delay": timedelta(hours=1),
"publish-safety": timedelta(hours=1),
"retire-safety": timedelta(hours=1),
"signatures-refresh": timedelta(days=5),
"signatures-validity": timedelta(days=14),
"zone-propagation-delay": timedelta(minutes=5),
}
def check_auth_nsec(response):
rrs = []
for rrset in response.authority:
if rrset.match(dns.rdataclass.IN, dns.rdatatype.NSEC, dns.rdatatype.NONE):
rrs.append(rrset)
assert not rrset.match(
dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE
)
assert len(rrs) != 0
@pytest.mark.parametrize(
"params",
[
pytest.param(
{
"zone": "nsec-to-nsec3.kasp",
"policy": "nsec",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec-to-nsec3.kasp",
),
pytest.param(
{
"zone": "rsasha1-to-nsec3.kasp",
"policy": "rsasha1",
"key-properties": [
f"csk 0 {RSASHA1.number} 2048 goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="rsasha1-to-nsec3.kasp",
marks=isctest.mark.with_algorithm("RSASHA1"),
),
pytest.param(
{
"zone": "rsasha1-to-nsec3-wait.kasp",
"policy": "rsasha1",
"key-properties": [
f"csk 0 {RSASHA1.number} 2048 goal:omnipresent dnskey:omnipresent krrsig:omnipresent zrrsig:omnipresent ds:omnipresent",
],
},
id="rsasha1-to-nsec3-wait.kasp",
marks=isctest.mark.with_algorithm("RSASHA1"),
),
pytest.param(
{
# This is a secondary zone, where the primary is signed with
# NSEC3 but the dnssec-policy dictates NSEC.
"zone": "nsec3-xfr-inline.kasp",
"policy": "nsec",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
"external-keys": [
f"csk 0 {ALGORITHM} {SIZE}",
],
"external-keydir": "ns2",
},
id="nsec3-xfr-inline.kasp",
),
pytest.param(
{
"zone": "nsec3-dynamic-update-inline.kasp",
"policy": "nsec",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-dynamic-update-inline.kasp",
),
],
)
def test_nsec_case(ns3, params):
# Get test parameters.
zone = params["zone"]
fqdn = f"{zone}."
policy = params["policy"]
keydir = ns3.identifier
config = default_config
ttl = int(config["dnskey-ttl"].total_seconds())
expected = isctest.kasp.policy_to_properties(ttl=ttl, keys=params["key-properties"])
# Test case.
isctest.log.info(f"check nsec case zone {zone} policy {policy}")
# First make sure the zone is properly signed.
isctest.kasp.wait_keymgr_done(ns3, zone)
# Key files.
keys = isctest.kasp.keydir_to_keylist(zone, keydir)
if "external-keys" in params:
expected2 = isctest.kasp.policy_to_properties(ttl, keys=params["external-keys"])
for ek in expected2:
ek.private = False # noqa
ek.legacy = True # noqa
expected = expected + expected2
assert "external-keydir" in params
extkeys = isctest.kasp.keydir_to_keylist(zone, params["external-keydir"])
keys = keys + extkeys
isctest.kasp.check_keys(zone, keys, expected)
isctest.kasp.check_dnssec_verify(ns3, zone)
isctest.kasp.check_apex(ns3, zone, keys, [])
query = isctest.query.create(fqdn, dns.rdatatype.NSEC3PARAM)
response = isctest.query.tcp(query, ns3.ip)
assert response.rcode() == dns.rcode.NOERROR
assert len(response.answer) == 0
check_auth_nsec(response)
query = isctest.query.create(f"nosuchname.{fqdn}", dns.rdatatype.A)
response = isctest.query.tcp(query, ns3.ip)
assert response.rcode() == dns.rcode.NXDOMAIN
check_auth_nsec(response)
# Extra test for nsec3-dynamic-update-inline.kasp.
if zone == "nsec3-dynamic-update-inline.kasp":
isctest.log.info(f"dynamic update dnssec-policy zone {zone} with NSEC3")
update_msg = dns.update.UpdateMessage(zone)
update_msg.add(
f"04O18462RI5903H8RDVL0QDT5B528DUJ.{zone}.",
3600,
"NSEC3",
"0 0 0 408A4B2D412A4E95 1JMDDPMTFF8QQLIOINSIG4CR9OTICAOC A RRSIG",
)
with ns3.watch_log_from_here() as watcher:
ns3.nsupdate(update_msg, expected_rcode=dns.rcode.REFUSED)
watcher.wait_for_line(
f"updating zone '{zone}/IN': update failed: explicit NSEC3 updates are not allowed in secure zones (REFUSED)"
)
def wait_for_soa_update(server, fqdn):
verified = False
match = f"20 20 1814400 900"
for _ in range(5):
query = isctest.query.create(fqdn, dns.rdatatype.SOA)
response = isctest.query.tcp(query, server.ip, server.ports.dns, timeout=3)
for rrset in response.answer:
if match in rrset.to_text():
verified = True
if verified:
break
time.sleep(1)
return verified
def check_nsec3param(response, match, saltlen):
rrs = []
for rrset in response.answer:
if rrset.match(dns.rdataclass.IN, dns.rdatatype.NSEC3PARAM, dns.rdatatype.NONE):
assert match in rrset.to_text()
if saltlen == 0:
assert f"{match} -" in rrset.to_text()
else:
assert not f"{match} -" in rrset.to_text()
rrs.append(rrset)
else:
assert rrset.match(
dns.rdataclass.IN, dns.rdatatype.RRSIG, dns.rdatatype.NSEC3PARAM
)
assert len(rrs) != 0
def check_auth_nsec3(response, iterations=0, optout=0, saltlen=0):
match = f"IN NSEC3 1 {optout} {iterations}"
rrs = []
for rrset in response.authority:
if rrset.match(dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE):
assert match in rrset.to_text()
if saltlen == 0:
assert f"{match} -" in rrset.to_text()
else:
assert not f"{match} -" in rrset.to_text()
rrs.append(rrset)
assert not rrset.match(
dns.rdataclass.IN, dns.rdatatype.NSEC, dns.rdatatype.NONE
)
assert len(rrs) != 0
@pytest.mark.parametrize(
"params",
[
pytest.param(
{
"zone": "nsec3-to-rsasha1.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-to-rsasha1.kasp",
marks=isctest.mark.with_algorithm("RSASHA1"),
),
pytest.param(
{
"zone": "nsec3-to-rsasha1-ds.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:omnipresent krrsig:omnipresent zrrsig:omnipresent ds:omnipresent",
],
},
id="nsec3-to-rsasha1-ds.kasp",
marks=isctest.mark.with_algorithm("RSASHA1"),
),
pytest.param(
{
"zone": "nsec3.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3.kasp",
),
pytest.param(
{
"zone": "nsec3-dynamic.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-dynamic.kasp",
),
pytest.param(
{
"zone": "nsec3-change.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-change.kasp",
),
pytest.param(
{
"zone": "nsec3-dynamic-change.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-dynamic-change.kasp",
),
pytest.param(
{
"zone": "nsec3-dynamic-to-inline.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-dynamic-to-inline.kasp",
),
pytest.param(
{
"zone": "nsec3-inline-to-dynamic.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-inline-to-dynamic.kasp",
),
pytest.param(
{
"zone": "nsec3-to-nsec.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-to-nsec.kasp",
),
pytest.param(
{
"zone": "nsec3-to-optout.kasp",
"policy": "nsec3",
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-to-optout.kasp",
),
pytest.param(
{
"zone": "nsec3-from-optout.kasp",
"policy": "optout",
"nsec3param": {
"optout": 1,
"salt-length": 0,
},
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-from-optout.kasp",
),
pytest.param(
{
"zone": "nsec3-other.kasp",
"policy": "nsec3-other",
"nsec3param": {
"optout": 1,
"salt-length": 8,
},
"key-properties": [
f"csk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:rumoured krrsig:rumoured zrrsig:rumoured ds:hidden",
],
},
id="nsec3-other.kasp",
),
],
)
def test_nsec3_case(ns3, params):
# Get test parameters.
zone = params["zone"]
fqdn = f"{zone}."
policy = params["policy"]
keydir = ns3.identifier
config = default_config
ttl = int(config["dnskey-ttl"].total_seconds())
expected = isctest.kasp.policy_to_properties(ttl=ttl, keys=params["key-properties"])
iterations = 0
optout = 0
saltlen = 0
if "nsec3param" in params:
optout = params["nsec3param"].get("optout", 0)
saltlen = params["nsec3param"].get("salt-length", 0)
match = f"{fqdn} 3600 IN NSEC3PARAM 1 0 {iterations}"
# Test case.
isctest.log.info(f"check nsec3 case zone {zone} policy {policy}")
# First make sure the zone is properly signed.
isctest.kasp.wait_keymgr_done(ns3, zone)
keys = isctest.kasp.keydir_to_keylist(zone, keydir)
isctest.kasp.check_keys(zone, keys, expected)
isctest.kasp.check_dnssec_verify(ns3, zone)
isctest.kasp.check_apex(ns3, zone, keys, [])
query = isctest.query.create(fqdn, dns.rdatatype.NSEC3PARAM)
response = isctest.query.tcp(query, ns3.ip)
assert response.rcode() == dns.rcode.NOERROR
check_nsec3param(response, match, saltlen)
query = isctest.query.create(f"nosuchname.{fqdn}", dns.rdatatype.A)
response = isctest.query.tcp(query, ns3.ip)
assert response.rcode() == dns.rcode.NXDOMAIN
check_auth_nsec3(response, iterations, optout, saltlen)
# Extra test for nsec3-change.kasp.
if zone == "nsec3-change.kasp":
shutil.copyfile(
f"{ns3.identifier}/template2.db.in", f"{ns3.identifier}/{zone}.db"
)
ns3.rndc(f"reload {zone}")
wait_for_soa_update(ns3, fqdn)
# After reconfig, the NSEC3PARAM TTL should match the new SOA MINIMUM.