From: Nicki Křížek Date: Mon, 8 Jun 2026 15:33:59 +0000 (+0000) Subject: Add malformed ECDSA DNSKEY tests to dnssec_py X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f1026e21bc1de4a4d83af20d6aca61a10f993d53;p=thirdparty%2Fbind9.git Add malformed ECDSA DNSKEY tests to dnssec_py Port test_malformed_ecdsa and test_multiple_rrsigs from the standalone dnssec_malformed_dnskey directory into the shared dnssec_py fixture harness. The zone is renamed from example. to dnskey-malformed., the resolver fixture changes from a dedicated ns3 to the shared ns9, and trust anchors are wired in via bootstrap() rather than per-directory config files. Assisted-by: Claude:claude-opus-4-8 --- diff --git a/bin/tests/system/dnssec_malformed_dnskey/ns2/named.conf.j2 b/bin/tests/system/dnssec_malformed_dnskey/ns2/named.conf.j2 index 75173e22f6d..cb89b52a982 100644 --- a/bin/tests/system/dnssec_malformed_dnskey/ns2/named.conf.j2 +++ b/bin/tests/system/dnssec_malformed_dnskey/ns2/named.conf.j2 @@ -9,16 +9,6 @@ options { allow-transfer { any; }; recursion no; dnssec-validation yes; - - /* Keep the order of RRSIGs in the response static. */ - rrset-order { - name "example." order none; - }; -}; - -zone example. { - type primary; - file "example.db.signed.malformed"; }; zone truncated.selfsigned. { diff --git a/bin/tests/system/dnssec_malformed_dnskey/ns2/trusted.conf.j2 b/bin/tests/system/dnssec_malformed_dnskey/ns2/trusted.conf.j2 index 1a0f3c959ef..30139fa5aab 100644 --- a/bin/tests/system/dnssec_malformed_dnskey/ns2/trusted.conf.j2 +++ b/bin/tests/system/dnssec_malformed_dnskey/ns2/trusted.conf.j2 @@ -1,6 +1,4 @@ trust-anchors { - example. static-key 257 3 14 "@ksk_public_key@"; - /* * The key tag in the trust anchor must match that of the revoked * truncated self-signed key in the truncated.selfsigned. zone. diff --git a/bin/tests/system/dnssec_malformed_dnskey/tests_malformed_dnskey.py b/bin/tests/system/dnssec_malformed_dnskey/tests_malformed_dnskey.py index cdb8932e7cd..cf14498b2ad 100644 --- a/bin/tests/system/dnssec_malformed_dnskey/tests_malformed_dnskey.py +++ b/bin/tests/system/dnssec_malformed_dnskey/tests_malformed_dnskey.py @@ -9,186 +9,10 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -from re import compile as Re - import base64 -import os - -from cryptography.hazmat.primitives.asymmetric import ec -from dns.rdtypes.dnskeybase import Flag - -import dns.dnssec -import dns.name -import dns.rdataclass -import dns.rdatatype -import dns.rdtypes.ANY.RRSIG -import dns.zone -import pytest - import isctest -def generate_key(): - algorithm = dns.dnssec.Algorithm.ECDSAP384SHA384 - ksk_private_key = ec.generate_private_key(ec.SECP384R1()) - 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 - - -MALFORMED_ZSK_KEY_TAG = 20071 - - -def create_malformed_rr(rr, n=0): - malformed_rr = dns.rdtypes.ANY.RRSIG.RRSIG( - rdclass=rr.rdclass, - rdtype=rr.rdtype, - type_covered=rr.type_covered, - algorithm=rr.algorithm, - labels=rr.labels, - original_ttl=rr.original_ttl - n, # edit TTL so multiple RRSIGs can be added - expiration=rr.expiration, - inception=rr.inception, - key_tag=MALFORMED_ZSK_KEY_TAG, # overwrite with the malformed ZSKs - signer=rr.signer, - signature=rr.signature, - ) - return malformed_rr - - -def bootstrap(): - zone = dns.zone.from_file("ns2/example.db.in", origin="example.") - lifetime = 300 - - # geneate KSK, avoid key tag collision with ZSKs - while True: - ksk_private_key, ksk_dnskey = generate_key() - if dns.dnssec.key_id(ksk_dnskey) != MALFORMED_ZSK_KEY_TAG: - break - keys = [(ksk_private_key, ksk_dnskey)] - - # sign the zone (including the malformed ZSKs) with KSK - with zone.writer() as txn: - dns.dnssec.sign_zone( - zone=zone, - txn=txn, - keys=keys, - lifetime=lifetime, - add_dnskey=True, - deterministic=False, # for OpenSSL<3.2.0 compat - ) - - # force use of the malformed ZSKs for dnssec verification - # malformed-dnskey.example. has only one invalid RRSIG and is only signed - # with malformed ZSKs - malformed_rrset = zone.get_rdataset("malformed-dnskey", "RRSIG", "A") - rr = malformed_rrset.pop() - malformed_rrset.add(create_malformed_rr(rr)) - - # multiple-rrsigs.example. contains a lot of RRSIGS with the same invalid - # signature using malformed RRSIG, and one valid RRSIG - multiple_rrset = zone.get_rdataset("multiple-rrsigs", "RRSIG", "A") - rr = multiple_rrset.pop() - for i in range(99): - multiple_rrset.add(create_malformed_rr(rr, i)) - multiple_rrset.add(rr) - - zone.to_file("ns2/example.db.signed.malformed") - - return { - "ksk_public_key": base64.b64encode(ksk_dnskey.key).decode(), - } - - -def test_malformed_ecdsa(ns3): - log_validation_failed = Re(r"malformed-dnskey\.example/A\): validation failed") - log_openssl_failure = Re("EVP_PKEY_fromdata.*failed") - log_openssl_version = Re("linked to OpenSSL version: OpenSSL ([0-9]+)") - - msg = isctest.query.create("malformed-dnskey.example", "A") - - openssl_vers = ns3.log.grep(log_openssl_version) - if ( - openssl_vers - and int(openssl_vers[0].group(1)) >= 3 - and os.getenv("FEATURE_QUERYTRACE") == "1" - ): - # extra check for OpenSSL 3.0.0+ - with ns3.watch_log_from_here() as watcher: - res = isctest.query.tcp(msg, "10.53.0.3") - - # check the OpenSSL-specific log message appears just once - matches = watcher.wait_for_all( - [ - log_openssl_failure, - log_validation_failed, - ] - ) - assert len([m for m in matches if m.re == log_openssl_failure]) == 1 - else: - res = isctest.query.tcp(msg, "10.53.0.3") - - isctest.check.servfail(res) - - -def test_multiple_rrsigs(ns3): - log_validation_failed = Re(r"multiple-rrsigs\.example/A\): validation failed") - log_openssl_failure = Re("EVP_PKEY_fromdata.*failed") - log_openssl_version = Re("linked to OpenSSL version: OpenSSL ([0-9]+)") - - msg = isctest.query.create("multiple-rrsigs.example", "A") - - # Check the order of returned RRSIGs from auth. Due to rrset-order none; - # this should remain constant for the remainder of the test. - # Ensure the first two RRSIGs are malformed, otherwise skip the test. - res = isctest.query.tcp(msg, "10.53.0.2") - rrsigs = res.get_rrset( - res.answer, - dns.name.from_text("multiple-rrsigs.example."), - dns.rdataclass.IN, - dns.rdatatype.RRSIG, - dns.rdatatype.A, - ) - assert len(rrsigs) > 2 - if ( - rrsigs[0].key_tag != MALFORMED_ZSK_KEY_TAG - or rrsigs[1].key_tag != MALFORMED_ZSK_KEY_TAG - ): - pytest.skip("valid RRSIG listed first in response, re-run test") - - openssl_vers = ns3.log.grep(log_openssl_version) - if ( - openssl_vers - and int(openssl_vers[0].group(1)) >= 3 - and os.getenv("FEATURE_QUERYTRACE") == "1" - ): - # extra check for OpenSSL 3.0.0+ - with ns3.watch_log_from_here() as watcher: - res = isctest.query.tcp(msg, "10.53.0.3") - - # check the OpenSSL-specific log message appears exactly twice: - # one failure is allowed by setting max-validation-failures-per-fetch 1; - matches = watcher.wait_for_all( - [ - log_openssl_failure, - log_validation_failed, - ] - ) - assert len([m for m in matches if m.re == log_openssl_failure]) == 2 - else: - res = isctest.query.tcp(msg, "10.53.0.3") - - isctest.check.servfail(res) - - def test_truncated_dnskey(): msg = isctest.query.create("a.truncated.selfsigned.", "A") res = isctest.query.tcp(msg, "10.53.0.3") diff --git a/bin/tests/system/dnssec_py/common.py b/bin/tests/system/dnssec_py/common.py index 56a30f0c416..8a9fbca4d24 100644 --- a/bin/tests/system/dnssec_py/common.py +++ b/bin/tests/system/dnssec_py/common.py @@ -15,5 +15,7 @@ DNSSEC_PY_MARK = pytest.mark.extra_artifacts( [ "ns*/dsset-*", "ns*/trusted.conf", + "ns*/zones/*.db", + "ns*/zones/*.db.signed", ] ) diff --git a/bin/tests/system/dnssec_malformed_dnskey/ns2/example.db.in b/bin/tests/system/dnssec_py/ns2/zones/dnskey-malformed.db.j2.manual similarity index 98% rename from bin/tests/system/dnssec_malformed_dnskey/ns2/example.db.in rename to bin/tests/system/dnssec_py/ns2/zones/dnskey-malformed.db.j2.manual index 70614af0c3b..5925b9701ae 100644 --- a/bin/tests/system/dnssec_malformed_dnskey/ns2/example.db.in +++ b/bin/tests/system/dnssec_py/ns2/zones/dnskey-malformed.db.j2.manual @@ -1,18 +1,10 @@ -$TTL 300 -@ IN SOA mname1. . ( - 1 ; serial - 600 ; refresh - 600 ; retry - 1200 ; expire - 600 ; minimum - ) - -@ NS @ -@ A 10.53.0.2 +{% include '_common/zones/soa.partial.db.j2' %} +{% include '_common/zones/ns.partial.db.j2' %} ; All of the following DNSKEYs are malformed and have the same key tag - 20071. ; The keys use invalid parameters for the ECDSA curve. +{% raw %} @ DNSKEY 256 3 14 rdZ3Mr7XEQoEdD5EF1z4ulhFFbCNxbiu1BvD9cNCeMAGu8qmCKB+KmHS3YPgZjMZtl1Wd/fvtHF/3sU3HoOfwFg5Y9Ytl2+URx5Or0NNksES2iAAwmRfEEnH/hzk+8xF @ DNSKEY 256 3 14 rdZ3Mr7XEQoEdD5EF1z4ulhFFbCNxbiu1BvD9cNCeMAGu8qmCKB+KmHS3YPgZjMZtl1Wd/fvtHF/3sU3HoOfjFg5Y9Ytl2+UR1JO/UNNksES2iAAwmRfEEnH/hzk+8v3 @ DNSKEY 256 3 14 rdZ3Mr7XEQoEdD5EF1z4ulhFFbCNxbiu1BvD9cNCeMAGu8qmCKB+KmHS3YPgZjMZtl1Wd/fvtHF/3sW2HoOfwFg5Y1ctl2+URx5O/UNNksES2iAAwmRfEEnH/hzk+8v3 @@ -112,6 +104,7 @@ $TTL 300 @ DNSKEY 256 3 14 rdZ3Mr7XEQoEdD5EF534ulhFFbCNxbiu1BvD9cNCeMAGu8qmCKB+KmHS3YPgZjMZtl1Wd/fvtHF/3sU3HoOfwFg5Y9Ytl2+URx5O/UNNksES2h+/wmRfEEnH/hzk+8v3 @ DNSKEY 256 3 14 rdZ3Mr7XEQoEdD5EF1z4ulhFFbCNxbiu1BvD9cNCeMAGu8qmCKB+KmHS3QngZjMZtl1Wd/fvtHF/3sU3HoOfwFg5ZFAtl2+URx5O/UNNksES2iAAwmRfEEnH/hzk+8v3 @ DNSKEY 256 3 14 rdZ3Mr7XEQoEdD5EF1z4ulhFFbCNxbiu1BvD9cNCeMAGu8qmCKB+KmHS3YPgZjMZtl1Wd/fvtHF/3sU3HoOfwFg5Y9Ytl2+URx5O/UNNksES2iAAwmRfEEnH/hzk+8v3 +{% endraw %} -malformed-dnskey A 10.53.0.2 +invalid-rrsig A 10.53.0.2 multiple-rrsigs A 10.53.0.2 diff --git a/bin/tests/system/dnssec_py/tests_dnskey_malformed.py b/bin/tests/system/dnssec_py/tests_dnskey_malformed.py new file mode 100644 index 00000000000..2972441b671 --- /dev/null +++ b/bin/tests/system/dnssec_py/tests_dnskey_malformed.py @@ -0,0 +1,213 @@ +# 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 pathlib import Path +from re import compile as Re + +import base64 +import os + +from cryptography.hazmat.primitives.asymmetric import ec +from dns.rdtypes.dnskeybase import Flag + +import dns.dnssec +import dns.name +import dns.rdataclass +import dns.rdatatype +import dns.rdtypes.ANY.RRSIG +import dns.zone +import pytest + +from dnssec_py.common import DNSSEC_PY_MARK +from isctest.template import NS2, TrustAnchor, zones +from isctest.zone import Zone, configure_root + +import isctest + +pytestmark = DNSSEC_PY_MARK + + +def generate_key(): + algorithm = dns.dnssec.Algorithm.ECDSAP384SHA384 + ksk_private_key = ec.generate_private_key(ec.SECP384R1()) + 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 + + +MALFORMED_ZSK_KEY_TAG = 20071 + + +def create_malformed_rr(rr, n=0): + malformed_rr = dns.rdtypes.ANY.RRSIG.RRSIG( + rdclass=rr.rdclass, + rdtype=rr.rdtype, + type_covered=rr.type_covered, + algorithm=rr.algorithm, + labels=rr.labels, + original_ttl=rr.original_ttl - n, # edit TTL so multiple RRSIGs can be added + expiration=rr.expiration, + inception=rr.inception, + key_tag=MALFORMED_ZSK_KEY_TAG, # overwrite with the malformed ZSKs + signer=rr.signer, + signature=rr.signature, + ) + return malformed_rr + + +def bootstrap(): + zone = Zone("dnskey-malformed", NS2, signed=True) + lifetime = 300 + + # generate KSK, avoid key tag collision with ZSKs + while True: + ksk_private_key, ksk_dnskey = generate_key() + if dns.dnssec.key_id(ksk_dnskey) != MALFORMED_ZSK_KEY_TAG: + break + keys = [(ksk_private_key, ksk_dnskey)] + + # render unsigned zone file + 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="dnskey-malformed.") + + # sign the zone (including the malformed ZSKs) with KSK + 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 + ) + + # force use of the malformed ZSKs for invalid-rrsig.dnskey-malformed; + # the record only has one invalid RRSIG signed with a malformed ZSK + invalid_rrset = zoneobj.get_rdataset("invalid-rrsig", "RRSIG", "A") + rr = invalid_rrset.pop() + invalid_rrset.add(create_malformed_rr(rr)) + + # multiple-rrsigs.dnskey-malformed contains a lot of RRSIGs with the same + # invalid signature using a malformed key, and one valid RRSIG + multiple_rrset = zoneobj.get_rdataset("multiple-rrsigs", "RRSIG", "A") + rr = multiple_rrset.pop() + for i in range(99): + multiple_rrset.add(create_malformed_rr(rr, i)) + multiple_rrset.add(rr) + + zoneobj.to_file(signed_path) + + root = configure_root([zone]) + ksk_key_b64 = base64.b64encode(ksk_dnskey.key).decode() + ksk_ta = TrustAnchor("dnskey-malformed", "static-key", f'257 3 14 "{ksk_key_b64}"') + + return { + "rrset_order_none": ["dnskey-malformed"], + "trust_anchors": [*root.trust_anchors(), ksk_ta], + "zones": zones([root, zone]), + } + + +def test_malformed_ecdsa(ns9): + log_validation_failed = Re( + r"invalid-rrsig\.dnskey-malformed/A\): validation failed" + ) + log_openssl_failure = Re("EVP_PKEY_fromdata.*failed") + log_openssl_version = Re("linked to OpenSSL version: OpenSSL ([0-9]+)") + + msg = isctest.query.create("invalid-rrsig.dnskey-malformed", "A") + + openssl_vers = ns9.log.grep(log_openssl_version) + if ( + openssl_vers + and int(openssl_vers[0].group(1)) >= 3 + and os.getenv("FEATURE_QUERYTRACE") == "1" + ): + # extra check for OpenSSL 3.0.0+ + with ns9.watch_log_from_here() as watcher: + res = isctest.query.tcp(msg, ns9.ip) + + # check the OpenSSL-specific log message appears just once + matches = watcher.wait_for_all( + [ + log_openssl_failure, + log_validation_failed, + ] + ) + assert len([m for m in matches if m.re == log_openssl_failure]) == 1 + else: + res = isctest.query.tcp(msg, ns9.ip) + + isctest.check.servfail(res) + + +def test_multiple_rrsigs(ns2, ns9): + log_validation_failed = Re( + r"multiple-rrsigs\.dnskey-malformed/A\): validation failed" + ) + log_openssl_failure = Re("EVP_PKEY_fromdata.*failed") + log_openssl_version = Re("linked to OpenSSL version: OpenSSL ([0-9]+)") + + msg = isctest.query.create("multiple-rrsigs.dnskey-malformed", "A") + + # Check the order of returned RRSIGs from auth. Due to rrset-order none; + # this should remain constant for the remainder of the test. + # Ensure the first two RRSIGs are malformed, otherwise skip the test. + res = isctest.query.tcp(msg, ns2.ip) + rrsigs = res.get_rrset( + res.answer, + dns.name.from_text("multiple-rrsigs.dnskey-malformed."), + dns.rdataclass.IN, + dns.rdatatype.RRSIG, + dns.rdatatype.A, + ) + assert len(rrsigs) > 2 + if ( + rrsigs[0].key_tag != MALFORMED_ZSK_KEY_TAG + or rrsigs[1].key_tag != MALFORMED_ZSK_KEY_TAG + ): + pytest.skip("valid RRSIG listed first in response, re-run test") + + openssl_vers = ns9.log.grep(log_openssl_version) + if ( + openssl_vers + and int(openssl_vers[0].group(1)) >= 3 + and os.getenv("FEATURE_QUERYTRACE") == "1" + ): + # extra check for OpenSSL 3.0.0+ + with ns9.watch_log_from_here() as watcher: + res = isctest.query.tcp(msg, ns9.ip) + + # check the OpenSSL-specific log message appears exactly twice: + # one failure is allowed by setting max-validation-failures-per-fetch 1; + matches = watcher.wait_for_all( + [ + log_openssl_failure, + log_validation_failed, + ] + ) + assert len([m for m in matches if m.re == log_openssl_failure]) == 2 + else: + res = isctest.query.tcp(msg, ns9.ip) + + isctest.check.servfail(res)