chg: test: Add malformed ECDSA DNSKEY tests to dnssec_py

Merge branch 'nicki/pytest-dnssec-py-dnskey-malformed' into 'main'

See merge request isc-projects/bind9!12210
This commit is contained in:
Nicki Křížek 2026-06-09 11:16:12 +02:00
commit b8c1b645ea
9 changed files with 109 additions and 119 deletions

View file

@ -1,29 +0,0 @@
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; };
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. {
type primary;
file "truncated.selfsigned.db.signed";
};
include "trusted.conf";

View file

@ -1,14 +0,0 @@
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.
*
* The DNSKEY contents are intentionally different here, because the
* key doesn't have the revoked bit here and that flag is part of the
* key tag. The following decodes to key tag 33167, which is the same
* as the revoked truncated key in the zone file.
*/
truncated.selfsigned. static-key 257 3 14 "fYA=";
};

View file

@ -1,26 +0,0 @@
options {
query-source address 10.53.0.3;
notify-source 10.53.0.3;
transfer-source 10.53.0.3;
port @PORT@;
pid-file "named.pid";
listen-on { 10.53.0.3; };
listen-on-v6 { none; };
allow-transfer { any; };
dnssec-validation yes;
/* This is the default, but the test relies on it. */
max-validation-failures-per-fetch 1;
};
zone "example." {
type static-stub;
server-addresses { 10.53.0.2; };
};
zone "truncated.selfsigned." {
type static-stub;
server-addresses { 10.53.0.2; };
};
include "trusted.conf";

View file

@ -1 +0,0 @@
../ns2/trusted.conf.j2

View file

@ -15,5 +15,7 @@ DNSSEC_PY_MARK = pytest.mark.extra_artifacts(
[
"ns*/dsset-*",
"ns*/trusted.conf",
"ns*/zones/*.db",
"ns*/zones/*.db.signed",
]
)

View file

@ -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

View file

@ -1,3 +1,4 @@
{% raw %}
$TTL 300
@ IN SOA mname1. . (
@ -27,3 +28,4 @@ a A 10.53.0.2
a RRSIG A 14 3 86400 20950926153053 20251013153053 33167 @ xxxxv31CNatB9xzj3AfTMlwiO0OqxbpJ cWrHN8zjj1ScXpqrHITfG/CZpoECDLWF wkXshDB/QMxHrnXkPKEcR2c9o5tcQT5R nHvtr7HT4Ob5PcY5DnItf3OWhE+bocmW
a NSEC @ A RRSIG NSEC
a RRSIG NSEC 14 3 0 20950926153053 20251013153053 33167 @ xxxxwMWbUxb3ScBKEVheQ2wFqujc6cyt 28GVCU0wPrBpK72HSsgdYme7IG8ZXGfa IWSU1Kf/om5+El7Tf2vDs7aI1yI7e7YG D5IxMejQg5v3/wtP7AJZXP5K9ICjq/ph
{% endraw %}

View file

@ -9,6 +9,7 @@
# 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
@ -25,8 +26,14 @@ 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
@ -65,20 +72,28 @@ def create_malformed_rr(rr, n=0):
def bootstrap():
zone = dns.zone.from_file("ns2/example.db.in", origin="example.")
zone = Zone("dnskey-malformed", NS2, signed=True)
lifetime = 300
# geneate KSK, avoid key tag collision with ZSKs
# 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 zone.writer() as txn:
with zoneobj.writer() as txn:
dns.dnssec.sign_zone(
zone=zone,
zone=zoneobj,
txn=txn,
keys=keys,
lifetime=lifetime,
@ -86,44 +101,51 @@ def bootstrap():
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))
# 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.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")
# 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)
zone.to_file("ns2/example.db.signed.malformed")
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 {
"ksk_public_key": base64.b64encode(ksk_dnskey.key).decode(),
"rrset_order_none": ["dnskey-malformed"],
"trust_anchors": [*root.trust_anchors(), ksk_ta],
"zones": zones([root, zone]),
}
def test_malformed_ecdsa(ns3):
log_validation_failed = Re(r"malformed-dnskey\.example/A\): validation failed")
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("malformed-dnskey.example", "A")
msg = isctest.query.create("invalid-rrsig.dnskey-malformed", "A")
openssl_vers = ns3.log.grep(log_openssl_version)
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 ns3.watch_log_from_here() as watcher:
res = isctest.query.tcp(msg, "10.53.0.3")
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(
@ -134,25 +156,27 @@ def test_malformed_ecdsa(ns3):
)
assert len([m for m in matches if m.re == log_openssl_failure]) == 1
else:
res = isctest.query.tcp(msg, "10.53.0.3")
res = isctest.query.tcp(msg, ns9.ip)
isctest.check.servfail(res)
def test_multiple_rrsigs(ns3):
log_validation_failed = Re(r"multiple-rrsigs\.example/A\): validation failed")
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.example", "A")
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, "10.53.0.2")
res = isctest.query.tcp(msg, ns2.ip)
rrsigs = res.get_rrset(
res.answer,
dns.name.from_text("multiple-rrsigs.example."),
dns.name.from_text("multiple-rrsigs.dnskey-malformed."),
dns.rdataclass.IN,
dns.rdatatype.RRSIG,
dns.rdatatype.A,
@ -164,15 +188,15 @@ def test_multiple_rrsigs(ns3):
):
pytest.skip("valid RRSIG listed first in response, re-run test")
openssl_vers = ns3.log.grep(log_openssl_version)
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 ns3.watch_log_from_here() as watcher:
res = isctest.query.tcp(msg, "10.53.0.3")
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;
@ -184,12 +208,6 @@ def test_multiple_rrsigs(ns3):
)
assert len([m for m in matches if m.re == log_openssl_failure]) == 2
else:
res = isctest.query.tcp(msg, "10.53.0.3")
res = isctest.query.tcp(msg, ns9.ip)
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")
isctest.check.servfail(res)

View file

@ -0,0 +1,45 @@
# 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 re import compile as Re
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 bootstrap():
zone = Zone("truncated.selfsigned", NS2, signed=True)
root = configure_root([zone], signed=False) # just delegation, TA is added directly
# The trust anchor key tag must match the revoked truncated self-signed key
# in the zone (key tag 33167). The flags differ here (257 vs 385) because
# the revoked bit is not part of the trust anchor, but it is part of the key
# tag calculation.
zone_ta = TrustAnchor("truncated.selfsigned", "static-key", '257 3 14 "fYA="')
return {
"trust_anchors": [zone_ta],
"zones": zones([root, zone]),
}
def test_truncated_dnskey(ns9):
msg = isctest.query.create("a.truncated.selfsigned.", "A")
with ns9.watch_log_from_here() as watcher:
res = isctest.query.tcp(msg, ns9.ip)
watcher.wait_for_line(Re("a.truncated.selfsigned/A.*broken trust chain"))
isctest.check.servfail(res)