]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Rewrite nsec3 system test to pytest (1/4)
authorMatthijs Mekking <matthijs@isc.org>
Tue, 30 Sep 2025 07:13:38 +0000 (09:13 +0200)
committerMatthijs Mekking <matthijs@isc.org>
Fri, 21 Nov 2025 08:38:47 +0000 (09:38 +0100)
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.

bin/tests/system/isctest/instance.py
bin/tests/system/nsec3/tests.sh
bin/tests/system/nsec3/tests_nsec3_initial.py [new file with mode: 0644]

index 268dcf09d0500119c559298409ac58ba3e497c48..d6400c5e39b6a84d8d33c095b9dffd8b97ce3685 100644 (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(
index bcb1144adae87f83a17d8e63296a4faffcba74d8..0414fb15f9a6a0db8325740a4076c6cfb8037b0c 100644 (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"
diff --git a/bin/tests/system/nsec3/tests_nsec3_initial.py b/bin/tests/system/nsec3/tests_nsec3_initial.py
new file mode 100644 (file)
index 0000000..42fbce4
--- /dev/null
@@ -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.