]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Treat unusable NSEC3PARAM as a verification failure 12408/head
authorEvan Hunt <each@isc.org>
Wed, 15 Jul 2026 06:02:11 +0000 (23:02 -0700)
committerEvan Hunt <each@isc.org>
Wed, 22 Jul 2026 05:29:09 +0000 (22:29 -0700)
dns_zoneverify_dnssec() could previously return success if the
NSEC3PARAM record was unusable, but no other NSEC/NSEC3 chains were
available. This has been fixed.

Co-Authored-By: Mark Andrews <marka@isc.org>
bin/tests/system/verify/tests_verify.py
bin/tests/system/verify/zones/bad-nsec3param-hash.db.j2.manual [new file with mode: 0644]
bin/tests/system/verify/zones/good-bad-nsec3param-hash.db.j2.manual [new file with mode: 0644]
bin/tests/system/verify/zones/no-nsec+non-zero-nsec3param-flags.db.j2.manual [new file with mode: 0644]
bin/tests/system/verify/zones/nsec+non-zero-nsec3param-flags.db.j2.manual [new file with mode: 0644]
bin/tests/system/verify/zones/nsec-badnsec3param-hash.db.j2.manual [new file with mode: 0644]
bin/tests/system/verify/zones/nseconly-nsec3param.db.j2.manual [new file with mode: 0644]
lib/dns/zoneverify.c

index 2fe4d30b2fd116d56cfcaa3dbe8e3a8369f9b514..609b5b60019977103e552a19961d322826d57486 100644 (file)
@@ -9,13 +9,28 @@
 # See the COPYRIGHT file distributed with this work for additional
 # information regarding copyright ownership.
 
+from pathlib import Path
 from re import compile as Re
 
 import os
 import re
-
+import time
+
+from cryptography.hazmat.primitives.asymmetric import rsa
+from dns.dnssectypes import NSEC3Hash
+from dns.rdtypes.dnskeybase import Flag
+
+import dns.dnssec
+import dns.name
+import dns.rdata
+import dns.rdataclass
+import dns.rdatatype
+import dns.zone
 import pytest
 
+from isctest.template import NO_NS, zones
+from isctest.zone import Zone
+
 import isctest
 
 pytestmark = pytest.mark.extra_artifacts(
@@ -28,12 +43,114 @@ pytestmark = pytest.mark.extra_artifacts(
         "zones/*.out*",
         "zones/*.tmp",
         "zones/updated*",
+        "zones/bad-nsec3param-hash*",
+        "zones/nsec-bad-nsec3param-hash*",
+        "zones/good-bad-nsec3param-hash*",
+        "zones/nsec+non-zero-nsec3param-flags*",
+        "zones/no-nsec+non-zero-nsec3param-flags*",
+        "zones/nseconly-nsec3param*",
     ]
 )
 
 VERIFY = os.environ.get("VERIFY")
 
 
+def bootstrap():
+
+    def generate_keys():
+        algorithm = dns.dnssec.Algorithm.RSASHA256
+        ksk_private_key = rsa.generate_private_key(public_exponent=65537, key_size=1024)
+        try:
+            ksk_dnskey = dns.dnssec.make_dnskey(
+                public_key=ksk_private_key.public_key(),
+                algorithm=algorithm,
+                flags=Flag.ZONE | Flag.SEP,
+            )
+        except ImportError as exc:
+            # if the cryptography package is too old, the make_dnskey() function
+            # will raise ImportError at runtime
+            pytest.skip(f"{exc}")
+        return ksk_private_key, ksk_dnskey
+
+    def gen_and_sign(name, nsec3=False):
+        zone = Zone(name, NO_NS, signed=True)
+        ksk_private_key, ksk_dnskey = generate_keys()
+        keys = [(ksk_private_key, ksk_dnskey)]
+        zone.render()
+
+        # read the rendered zone
+        unsigned_path = str(Path(zone.ns.name) / zone.filepath_unsigned)
+        signed_path = str(Path(zone.ns.name) / zone.filepath_signed)
+        zoneobj = dns.zone.from_file(unsigned_path, origin=f"{name}.")
+        lifetime = 30 * 86400
+
+        # sign the zone
+        with zoneobj.writer() as txn:
+            dns.dnssec.sign_zone(
+                zone=zoneobj,
+                txn=txn,
+                keys=keys,
+                lifetime=lifetime,
+                add_dnskey=True,
+                deterministic=False,  # for OpenSSL<3.2.0 compat
+            )
+
+        # This generates an NSEC3 record for the apex, so that we can
+        # verify a zone with both a valid and invalid NSEC3PARAM record.
+        if nsec3:
+            origin = dns.name.from_text(f"{name}.")
+            hashname = dns.dnssec.nsec3_hash(
+                origin, salt=b"", iterations=0, algorithm=NSEC3Hash.SHA1
+            )
+            nsec3rdata = dns.rdata.from_text(
+                rdclass=dns.rdataclass.IN,
+                rdtype=dns.rdatatype.NSEC3,
+                tok=f"1 0 0 - {hashname} SOA NS A NSEC3PARAM DNSKEY RRSIG",
+            )
+            nsec3_owner = dns.name.from_text(f"{hashname}.{name}.")
+            node = zoneobj.find_node(nsec3_owner, create=True)
+            nsec3_rrset = node.find_rdataset(
+                rdclass=dns.rdataclass.IN, rdtype=dns.rdatatype.NSEC3, create=True
+            )
+            nsec3_rrset.ttl = 300
+            nsec3_rrset.add(nsec3rdata)
+
+            inception = int(time.time()) - 30
+            nsec3_sigdata = dns.dnssec.sign(
+                rrset=(nsec3_owner, nsec3_rrset),
+                private_key=ksk_private_key,
+                signer=origin,
+                dnskey=ksk_dnskey,
+                inception=inception,
+                lifetime=lifetime,
+            )
+            nsec3_rrsig = node.find_rdataset(
+                rdclass=dns.rdataclass.IN,
+                rdtype=dns.rdatatype.RRSIG,
+                covers=dns.rdatatype.NSEC3,
+                create=True,
+            )
+            nsec3_rrsig.ttl = 300
+            nsec3_rrsig.add(nsec3_sigdata)
+
+        zoneobj.to_file(signed_path)
+
+        return zone
+
+    return {
+        "zones": zones(
+            [
+                gen_and_sign("bad-nsec3param-hash"),
+                gen_and_sign("nsec-bad-nsec3param-hash"),
+                gen_and_sign("good-bad-nsec3param-hash", True),
+                gen_and_sign("nsec+non-zero-nsec3param-flags"),
+                gen_and_sign("no-nsec+non-zero-nsec3param-flags"),
+                gen_and_sign("nseconly-nsec3param", True),
+            ]
+        ),
+    }
+
+
 @pytest.mark.parametrize(
     "zone",
     [
@@ -176,3 +293,86 @@ def test_verify_j_reads_journal_file():
         ]
     )
     assert "Loading zone 'updated' from file 'zones/updated.other'" in cmd.out
+
+
+# checking that unknown hash is detected
+def test_verify_rejects_bad_nsec3param_hash():
+    cmd = isctest.run.cmd(
+        [
+            VERIFY,
+            "-z",
+            "-o",
+            "bad-nsec3param-hash",
+            "zones/bad-nsec3param-hash.db.signed",
+        ],
+        raise_on_exception=False,
+    )
+    assert cmd.rc != 0
+    assert "No usable NSEC/NSEC3 chain for testing" in cmd.err
+
+
+def test_verify_ignores_bad_nsec3param_hash_with_nsec():
+    isctest.run.cmd(
+        [
+            VERIFY,
+            "-z",
+            "-o",
+            "nsec-bad-nsec3param-hash",
+            "zones/nsec-bad-nsec3param-hash.db.signed",
+        ]
+    )
+
+
+def test_verify_ignores_bad_nsec3param_hash_with_good_nsec3param():
+    isctest.run.cmd(
+        [
+            VERIFY,
+            "-z",
+            "-o",
+            "good-bad-nsec3param-hash",
+            "zones/good-bad-nsec3param-hash.db.signed",
+        ]
+    )
+
+
+def test_verify_rejects_no_nsec_and_all_nsec3param_with_non_zero_flags():
+    cmd = isctest.run.cmd(
+        [
+            VERIFY,
+            "-z",
+            "-o",
+            "no-nsec+non-zero-nsec3param-flags",
+            "zones/no-nsec+non-zero-nsec3param-flags.db.signed",
+        ],
+        raise_on_exception=False,
+    )
+    assert cmd.rc != 0
+    assert "No usable NSEC/NSEC3 chain for testing" in cmd.err
+
+
+def test_verify_ignores_nsec3param_non_zero_flags():
+    isctest.run.cmd(
+        [
+            VERIFY,
+            "-z",
+            "-o",
+            "nsec+non-zero-nsec3param-flags",
+            "zones/nsec+non-zero-nsec3param-flags.db.signed",
+        ],
+    )
+
+
+def test_verify_rejects_nsec3param_with_nsec_only_key():
+    cmd = isctest.run.cmd(
+        [
+            VERIFY,
+            "-z",
+            "-o",
+            "nseconly-nsec3param",
+            "zones/nseconly-nsec3param.db.signed",
+        ],
+        raise_on_exception=False,
+    )
+    assert cmd.rc != 0
+    isctest.log.debug(cmd.err)
+    assert "cannot use NSEC3 with key algorithm" in cmd.out
diff --git a/bin/tests/system/verify/zones/bad-nsec3param-hash.db.j2.manual b/bin/tests/system/verify/zones/bad-nsec3param-hash.db.j2.manual
new file mode 100644 (file)
index 0000000..f3052a9
--- /dev/null
@@ -0,0 +1,7 @@
+{% include '_common/zones/soa.partial.db.j2' %}
+{% include '_common/zones/ns.partial.db.j2' %}
+
+; This NSEC3PARAM record is unusable and should cause verification failure.
+{% raw %}
+@              NSEC3PARAM 2 0 0 -
+{% endraw %}
diff --git a/bin/tests/system/verify/zones/good-bad-nsec3param-hash.db.j2.manual b/bin/tests/system/verify/zones/good-bad-nsec3param-hash.db.j2.manual
new file mode 100644 (file)
index 0000000..354ae4d
--- /dev/null
@@ -0,0 +1,8 @@
+{% include '_common/zones/soa.partial.db.j2' %}
+{% include '_common/zones/ns.partial.db.j2' %}
+
+; This NSEC3PARAM record is unusable and should cause verification failure.
+{% raw %}
+@              NSEC3PARAM 1 0 0 -
+@              NSEC3PARAM 2 0 0 -
+{% endraw %}
diff --git a/bin/tests/system/verify/zones/no-nsec+non-zero-nsec3param-flags.db.j2.manual b/bin/tests/system/verify/zones/no-nsec+non-zero-nsec3param-flags.db.j2.manual
new file mode 100644 (file)
index 0000000..0851753
--- /dev/null
@@ -0,0 +1,10 @@
+{% include '_common/zones/soa.partial.db.j2' %}
+{% include '_common/zones/ns.partial.db.j2' %}
+
+; This NSEC3PARAM record should be ignored.  It does not
+; indicate that there is a valid NSEC3 chain.  As there
+; isn't another NSEC3PARAM record with zero flags nor a
+; NSEC record this zone should be rejected.
+{% raw %}
+@              NSEC3PARAM 1 1 0 -
+{% endraw %}
diff --git a/bin/tests/system/verify/zones/nsec+non-zero-nsec3param-flags.db.j2.manual b/bin/tests/system/verify/zones/nsec+non-zero-nsec3param-flags.db.j2.manual
new file mode 100644 (file)
index 0000000..e66995b
--- /dev/null
@@ -0,0 +1,9 @@
+{% include '_common/zones/soa.partial.db.j2' %}
+{% include '_common/zones/ns.partial.db.j2' %}
+
+; This NSEC3PARAM record should be ignored.  It does not
+; indicate that there is a valid NSEC3 chain.
+{% raw %}
+@              NSEC @ SOA NS A NSEC3PARAM DNSKEY NSEC RRSIG
+@              NSEC3PARAM 1 1 0 -
+{% endraw %}
diff --git a/bin/tests/system/verify/zones/nsec-badnsec3param-hash.db.j2.manual b/bin/tests/system/verify/zones/nsec-badnsec3param-hash.db.j2.manual
new file mode 100644 (file)
index 0000000..d0b976b
--- /dev/null
@@ -0,0 +1,8 @@
+{% include '_common/zones/soa.partial.db.j2' %}
+{% include '_common/zones/ns.partial.db.j2' %}
+
+; This NSEC3PARAM record is unusable but should be ignored since NSEC works.
+{% raw %}
+@              NSEC @ SOA NS A NSEC3PARAM DNSKEY NSEC RRSIG
+@              NSEC3PARAM 2 0 0 -
+{% endraw %}
diff --git a/bin/tests/system/verify/zones/nseconly-nsec3param.db.j2.manual b/bin/tests/system/verify/zones/nseconly-nsec3param.db.j2.manual
new file mode 100644 (file)
index 0000000..c44af87
--- /dev/null
@@ -0,0 +1,8 @@
+{% include '_common/zones/soa.partial.db.j2' %}
+{% include '_common/zones/ns.partial.db.j2' %}
+
+; This NSEC3PARAM record is valid but the DNSKEY has an NSEC-only algorithm
+{% raw %}
+@              NSEC3PARAM 1 0 0 -
+@              DNSKEY 257 3 5 BEAAAAOlYGw53D+f01yCL5JsP0SB6EjYrnd0JYRBooAaGPT+Q0kpiN+7GviFh+nIazoB8e2Yv7mupgqkmIjObdcbGstYpUltdECdNpNmBvASKB9SBdtGeRvXXpORi3Qyxb9kHGG7SpzyYbc+KDVKnzYHB94pvqu3ZZpPFPBFtCibp/mkhw==
+{% endraw %}
index aeb9b1e5b3e379fbc22fe4e246fb39ba787cb6a9..2050f275dd5578a03de72348b6efe090fb4df6b9 100644 (file)
@@ -60,6 +60,7 @@ typedef struct vctx {
        dns_keytable_t *secroots;
        bool goodksk;
        bool goodzsk;
+       bool nseconly;
        dns_rdataset_t keyset;
        dns_rdataset_t keysigs;
        dns_rdataset_t soaset;
@@ -605,17 +606,16 @@ cleanup:
 static isc_result_t
 isoptout(const vctx_t *vctx, const dns_rdata_nsec3param_t *nsec3param,
         bool *optout) {
-       dns_rdataset_t rdataset;
+       dns_rdataset_t rdataset = DNS_RDATASET_INIT;
        dns_rdata_t rdata = DNS_RDATA_INIT;
        dns_rdata_nsec3_t nsec3;
        dns_fixedname_t fixed;
-       dns_name_t *hashname;
+       dns_name_t *hashname = dns_fixedname_initname(&fixed);
        isc_result_t result;
        dns_dbnode_t *node = NULL;
        unsigned char rawhash[NSEC3_MAX_HASH_LENGTH];
        size_t rhsize = sizeof(rawhash);
 
-       dns_fixedname_init(&fixed);
        result = dns_nsec3_hashname(
                &fixed, rawhash, &rhsize, vctx->origin, vctx->origin,
                nsec3param->hash, nsec3param->iterations, nsec3param->salt.base,
@@ -626,27 +626,21 @@ isoptout(const vctx_t *vctx, const dns_rdata_nsec3param_t *nsec3param,
                return result;
        }
 
-       dns_rdataset_init(&rdataset);
-       hashname = dns_fixedname_name(&fixed);
        result = dns_db_findnsec3node(vctx->db, hashname, false, &node);
        if (result == ISC_R_SUCCESS) {
                result = dns_db_findrdataset(vctx->db, node, vctx->ver,
                                             dns_rdatatype_nsec3, 0, 0,
                                             &rdataset, NULL);
        }
+       if (result == ISC_R_SUCCESS) {
+               result = dns_rdataset_first(&rdataset);
+       }
        if (result != ISC_R_SUCCESS) {
                *optout = false;
                result = ISC_R_SUCCESS;
                goto done;
        }
 
-       result = dns_rdataset_first(&rdataset);
-       if (result != ISC_R_SUCCESS) {
-               zoneverify_log_error(vctx, "dns_rdataset_first(): %s",
-                                    isc_result_totext(result));
-               goto done;
-       }
-
        dns_rdataset_current(&rdataset, &rdata);
 
        result = dns_rdata_tostruct(&rdata, &nsec3, NULL);
@@ -664,49 +658,36 @@ done:
 
 static isc_result_t
 verifynsec3(const vctx_t *vctx, const dns_name_t *name,
-           const dns_rdata_t *rdata, bool delegation, bool empty,
-           const unsigned char types[8192], unsigned int maxtype,
+           const dns_rdata_nsec3param_t *nsec3param, bool delegation,
+           bool empty, const unsigned char types[8192], unsigned int maxtype,
            isc_result_t *vresult) {
        char namebuf[DNS_NAME_FORMATSIZE];
        char hashbuf[DNS_NAME_FORMATSIZE];
-       dns_rdataset_t rdataset;
-       dns_rdata_nsec3param_t nsec3param;
+       dns_rdataset_t rdataset = DNS_RDATASET_INIT;
        dns_fixedname_t fixed;
-       dns_name_t *hashname;
-       isc_result_t result, tvresult = ISC_R_UNSET;
+       dns_name_t *hashname = dns_fixedname_initname(&fixed);
+       isc_result_t result;
        dns_dbnode_t *node = NULL;
        unsigned char rawhash[NSEC3_MAX_HASH_LENGTH];
        size_t rhsize = sizeof(rawhash);
        bool optout = false;
 
-       result = dns_rdata_tostruct(rdata, &nsec3param, NULL);
-       RUNTIME_CHECK(result == ISC_R_SUCCESS);
-
-       if (nsec3param.flags != 0) {
-               return ISC_R_SUCCESS;
-       }
-
-       if (!dns_nsec3_supportedhash(nsec3param.hash)) {
-               return ISC_R_SUCCESS;
-       }
+       INSIST(nsec3param->flags == 0);
 
-       if (nsec3param.iterations > DNS_NSEC3_MAXITERATIONS) {
-               result = DNS_R_NSEC3ITERRANGE;
-               zoneverify_log_error(vctx, "verifynsec3: %s",
-                                    isc_result_totext(result));
+       result = isoptout(vctx, nsec3param, &optout);
+       if (result != ISC_R_SUCCESS) {
+               *vresult = result;
                return result;
        }
 
-       RETERR(isoptout(vctx, &nsec3param, &optout));
-
-       dns_fixedname_init(&fixed);
-       result = dns_nsec3_hashname(&fixed, rawhash, &rhsize, name,
-                                   vctx->origin, nsec3param.hash,
-                                   nsec3param.iterations, nsec3param.salt.base,
-                                   nsec3param.salt.length);
+       result = dns_nsec3_hashname(
+               &fixed, rawhash, &rhsize, name, vctx->origin, nsec3param->hash,
+               nsec3param->iterations, nsec3param->salt.base,
+               nsec3param->salt.length);
        if (result != ISC_R_SUCCESS) {
                zoneverify_log_error(vctx, "dns_nsec3_hashname(): %s",
                                     isc_result_totext(result));
+               *vresult = result;
                return result;
        }
 
@@ -716,8 +697,6 @@ verifynsec3(const vctx_t *vctx, const dns_name_t *name,
         * from dnssec-signzone so the secure status of the zone may not
         * be up to date.
         */
-       dns_rdataset_init(&rdataset);
-       hashname = dns_fixedname_name(&fixed);
        result = dns_db_findnsec3node(vctx->db, hashname, false, &node);
        if (result == ISC_R_SUCCESS) {
                result = dns_db_findrdataset(vctx->db, node, vctx->ver,
@@ -736,9 +715,11 @@ verifynsec3(const vctx_t *vctx, const dns_name_t *name,
        {
                result = ISC_R_SUCCESS;
        } else if (result == ISC_R_SUCCESS) {
-               result = match_nsec3(vctx, name, &nsec3param, &rdataset, types,
+               isc_result_t tvresult = ISC_R_UNSET;
+               result = match_nsec3(vctx, name, nsec3param, &rdataset, types,
                                     maxtype, rawhash, rhsize, &tvresult);
                if (result != ISC_R_SUCCESS) {
+                       *vresult = tvresult;
                        goto done;
                }
                result = tvresult;
@@ -762,11 +743,37 @@ verifynsec3s(const vctx_t *vctx, const dns_name_t *name,
             const unsigned char types[8192], unsigned int maxtype,
             isc_result_t *vresult) {
        DNS_RDATASET_FOREACH(nsec3paramset) {
+               isc_result_t result;
                dns_rdata_t rdata = DNS_RDATA_INIT;
+               dns_rdata_nsec3param_t nsec3param;
 
                dns_rdataset_current(nsec3paramset, &rdata);
-               RETERR(verifynsec3(vctx, name, &rdata, delegation, empty, types,
-                                  maxtype, vresult));
+
+               result = dns_rdata_tostruct(&rdata, &nsec3param, NULL);
+               RUNTIME_CHECK(result == ISC_R_SUCCESS);
+
+               /* Skip unusable NSEC3PARAM records. */
+               if (nsec3param.flags != 0) {
+                       continue;
+               }
+
+               /*
+                * If an NSEC-only algorithm is in the DNSKEY set,
+                * any NSEC3PARAM with flags == 0 is an error.
+                */
+               if (vctx->nseconly) {
+                       *vresult = DNS_R_NSEC3BADALG;
+                       break;
+               }
+
+               if (nsec3param.iterations > DNS_NSEC3_MAXITERATIONS ||
+                   !dns_nsec3_supportedhash(nsec3param.hash))
+               {
+                       continue;
+               }
+
+               RETERR(verifynsec3(vctx, name, &nsec3param, delegation, empty,
+                                  types, maxtype, vresult));
                if (*vresult != ISC_R_SUCCESS) {
                        break;
                }
@@ -1276,21 +1283,17 @@ static isc_result_t
 check_apex_rrsets(vctx_t *vctx) {
        dns_dbnode_t *node = NULL;
        isc_result_t result;
+       bool nsec3param_ok = false;
 
-       result = dns_db_findnode(vctx->db, vctx->origin, false, &node);
-       if (result != ISC_R_SUCCESS) {
-               zoneverify_log_error(vctx,
-                                    "failed to find the zone's origin: %s",
-                                    isc_result_totext(result));
-               return result;
+       result = dns_db_getoriginnode(vctx->db, &node);
+       if (result == ISC_R_SUCCESS) {
+               result = dns_db_findrdataset(vctx->db, node, vctx->ver,
+                                            dns_rdatatype_dnskey, 0, 0,
+                                            &vctx->keyset, &vctx->keysigs);
        }
-
-       result = dns_db_findrdataset(vctx->db, node, vctx->ver,
-                                    dns_rdatatype_dnskey, 0, 0, &vctx->keyset,
-                                    &vctx->keysigs);
        if (result != ISC_R_SUCCESS) {
                zoneverify_log_error(vctx, "Zone contains no DNSSEC keys");
-               goto done;
+               CLEANUP(result);
        }
 
        result = dns_db_findrdataset(vctx->db, node, vctx->ver,
@@ -1298,7 +1301,7 @@ check_apex_rrsets(vctx_t *vctx) {
                                     &vctx->soasigs);
        if (result != ISC_R_SUCCESS) {
                zoneverify_log_error(vctx, "Zone contains no SOA record");
-               goto done;
+               CLEANUP(result)
        }
 
        result = dns_db_findrdataset(vctx->db, node, vctx->ver,
@@ -1306,7 +1309,7 @@ check_apex_rrsets(vctx_t *vctx) {
                                     &vctx->nsecsigs);
        if (result != ISC_R_SUCCESS && result != ISC_R_NOTFOUND) {
                zoneverify_log_error(vctx, "NSEC lookup failed");
-               goto done;
+               CLEANUP(result);
        }
 
        result = dns_db_findrdataset(
@@ -1314,21 +1317,19 @@ check_apex_rrsets(vctx_t *vctx) {
                &vctx->nsec3paramset, &vctx->nsec3paramsigs);
        if (result != ISC_R_SUCCESS && result != ISC_R_NOTFOUND) {
                zoneverify_log_error(vctx, "NSEC3PARAM lookup failed");
-               goto done;
+               CLEANUP(result);
        }
 
        if (!dns_rdataset_isassociated(&vctx->keysigs)) {
                zoneverify_log_error(vctx, "DNSKEY is not signed "
                                           "(keys offline or inactive?)");
-               result = ISC_R_FAILURE;
-               goto done;
+               CLEANUP(ISC_R_FAILURE);
        }
 
        if (!dns_rdataset_isassociated(&vctx->soasigs)) {
                zoneverify_log_error(vctx, "SOA is not signed "
                                           "(keys offline or inactive?)");
-               result = ISC_R_FAILURE;
-               goto done;
+               CLEANUP(ISC_R_FAILURE);
        }
 
        if (dns_rdataset_isassociated(&vctx->nsecset) &&
@@ -1336,8 +1337,7 @@ check_apex_rrsets(vctx_t *vctx) {
        {
                zoneverify_log_error(vctx, "NSEC is not signed "
                                           "(keys offline or inactive?)");
-               result = ISC_R_FAILURE;
-               goto done;
+               CLEANUP(ISC_R_FAILURE);
        }
 
        if (dns_rdataset_isassociated(&vctx->nsec3paramset) &&
@@ -1345,22 +1345,41 @@ check_apex_rrsets(vctx_t *vctx) {
        {
                zoneverify_log_error(vctx, "NSEC3PARAM is not signed "
                                           "(keys offline or inactive?)");
-               result = ISC_R_FAILURE;
-               goto done;
+               CLEANUP(ISC_R_FAILURE);
        }
 
-       if (!dns_rdataset_isassociated(&vctx->nsecset) &&
-           !dns_rdataset_isassociated(&vctx->nsec3paramset))
-       {
-               zoneverify_log_error(vctx, "No valid NSEC/NSEC3 chain for "
+       /*
+        * Do we have a NSEC3PARAM record that indicates a complete
+        * chain?  A forged NSEC3PARAM set will be detected later.
+        */
+       if (dns_rdataset_isassociated(&vctx->nsec3paramset)) {
+               DNS_RDATASET_FOREACH(&vctx->nsec3paramset) {
+                       dns_rdata_t rdata = DNS_RDATA_INIT;
+                       dns_rdata_nsec3param_t nsec3param;
+
+                       dns_rdataset_current(&vctx->nsec3paramset, &rdata);
+                       result = dns_rdata_tostruct(&rdata, &nsec3param, NULL);
+                       RUNTIME_CHECK(result == ISC_R_SUCCESS);
+                       if (nsec3param.flags != 0 ||
+                           nsec3param.iterations > DNS_NSEC3_MAXITERATIONS ||
+                           !dns_nsec3_supportedhash(nsec3param.hash))
+                       {
+                               continue;
+                       }
+                       nsec3param_ok = true;
+                       break;
+               }
+       }
+
+       if (!dns_rdataset_isassociated(&vctx->nsecset) && !nsec3param_ok) {
+               zoneverify_log_error(vctx, "No usable NSEC/NSEC3 chain for "
                                           "testing");
-               result = ISC_R_FAILURE;
-               goto done;
+               CLEANUP(ISC_R_FAILURE);
        }
 
        result = ISC_R_SUCCESS;
 
-done:
+cleanup:
        dns_db_detachnode(&node);
 
        return result;
@@ -1768,10 +1787,7 @@ verify_nodes(vctx_t *vctx, isc_result_t *vresult) {
                        dns_db_detachnode(&node);
                        goto done;
                }
-               if (*vresult == ISC_R_UNSET) {
-                       *vresult = ISC_R_SUCCESS;
-               }
-               if (*vresult == ISC_R_SUCCESS) {
+               if (*vresult == ISC_R_UNSET || *vresult == ISC_R_SUCCESS) {
                        *vresult = tvresult;
                }
                if (prevname != NULL) {
@@ -1926,6 +1942,10 @@ dns_zoneverify_dnssec(dns_zone_t *zone, dns_db_t *db, dns_dbversion_t *ver,
                goto done;
        }
 
+       /* Record whether NSEC3 is supported by the DNSKEY RRset */
+       result = dns_nsec_nseconly(vctx.db, vctx.ver, NULL, &vctx.nseconly);
+       RUNTIME_CHECK(result == ISC_R_SUCCESS);
+
        determine_active_algorithms(&vctx, ignore_kskflag, keyset_kskonly,
                                    report);
 
@@ -1935,10 +1955,9 @@ dns_zoneverify_dnssec(dns_zone_t *zone, dns_db_t *db, dns_dbversion_t *ver,
        }
 
        result = verify_nsec3_chains(&vctx, mctx);
-       if (vresult == ISC_R_UNSET) {
-               vresult = ISC_R_SUCCESS;
-       }
-       if (result != ISC_R_SUCCESS && vresult == ISC_R_SUCCESS) {
+       if (result != ISC_R_SUCCESS &&
+           (vresult == ISC_R_SUCCESS || vresult == ISC_R_UNSET))
+       {
                vresult = result;
        }