From 8e62910557110c7fb2748efb7de70de7e6e61f76 Mon Sep 17 00:00:00 2001 From: Zuyao <91380691+TochusC@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:02:12 +0800 Subject: [PATCH 1/3] Test RRSIG validity across the 32-bit rollover --- tests/test_dnssec.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_dnssec.py b/tests/test_dnssec.py index e69bebcd..8d67143c 100644 --- a/tests/test_dnssec.py +++ b/tests/test_dnssec.py @@ -1089,6 +1089,31 @@ def testTimeBounds(self): abs_ed448_mx, abs_ed448_mx_rrsig_1, abs_ed448_keys_1, None ) + def testTimeBoundsAcrossRollover(self) -> None: + key = ed25519.Ed25519PrivateKey.generate() + signer = abs_soa.name + dnskey = dns.dnssec.make_dnskey(key.public_key(), dns.dnssec.Algorithm.ED25519) + keys = {signer: dns.rrset.from_rdata(signer, 300, dnskey)} + rollover = 2**32 + rrsig = dns.dnssec.sign( + abs_soa, key, signer, dnskey, inception=rollover - 16, expiration=32 + ) + + for now in (rollover - 16, rollover - 1, rollover + 1, rollover + 32): + with self.subTest(now=now): + dns.dnssec.validate_rrsig(abs_soa, rrsig, keys, now=now) + + for now, message in ( + (rollover - 16.5, "not yet valid"), + (rollover + 32.5, "expired"), + (2**31 + 32, "expired"), # Undefined ordering relative to expiration. + (float("nan"), "invalid validation time"), + (float("inf"), "invalid validation time"), + ): + with self.subTest(now=now): + with self.assertRaisesRegex(dns.dnssec.ValidationFailure, message): + dns.dnssec.validate_rrsig(abs_soa, rrsig, keys, now=now) + def testOwnerNameMismatch(self): bogus = dns.name.from_text("example.bogus") with self.assertRaises(dns.dnssec.ValidationFailure): From b278cff35e361d6242702cfd0304f04efd904860 Mon Sep 17 00:00:00 2001 From: Zuyao <91380691+TochusC@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:04:50 +0800 Subject: [PATCH 2/3] Use serial number arithmetic for RRSIG validity times --- dns/dnssec.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/dns/dnssec.py b/dns/dnssec.py index 7304ccc9..e989c502 100644 --- a/dns/dnssec.py +++ b/dns/dnssec.py @@ -23,6 +23,7 @@ import contextlib import functools import hashlib +import math import struct import time from collections.abc import Callable @@ -37,6 +38,7 @@ import dns.rdataset import dns.rdatatype import dns.rrset +import dns.serial import dns.transaction import dns.zone from dns.dnssectypes import Algorithm, DSDigest, NSEC3Hash @@ -369,9 +371,18 @@ def _validate_rrsig( if now is None: now = time.time() - if rrsig.expiration < now: + try: + now_seconds = math.floor(now) + except (OverflowError, ValueError) as exc: + raise ValidationFailure("invalid validation time") from exc + + # RFC 4034 section 3.1.5: compare RRSIG times as 32-bit serial numbers. + current = dns.serial.Serial(now_seconds) + expiration = dns.serial.Serial(rrsig.expiration) + # The exact expiration instant is inclusive, even when now is fractional. + if not (current <= expiration) or (current == expiration and now > now_seconds): raise ValidationFailure("expired") - if rrsig.inception > now: + if not (dns.serial.Serial(rrsig.inception) <= current): raise ValidationFailure("not yet valid") data = _make_rrsig_signature_data(rrset, rrsig, origin) From d04be9df9fb1cdb824ac160ef2beeeebd4bbe65b Mon Sep 17 00:00:00 2001 From: Zuyao <91380691+TochusC@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:13:36 +0800 Subject: [PATCH 3/3] Test RRSIG validity at the serial half-range --- tests/test_dnssec.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_dnssec.py b/tests/test_dnssec.py index 8d67143c..e744d093 100644 --- a/tests/test_dnssec.py +++ b/tests/test_dnssec.py @@ -1106,7 +1106,6 @@ def testTimeBoundsAcrossRollover(self) -> None: for now, message in ( (rollover - 16.5, "not yet valid"), (rollover + 32.5, "expired"), - (2**31 + 32, "expired"), # Undefined ordering relative to expiration. (float("nan"), "invalid validation time"), (float("inf"), "invalid validation time"), ): @@ -1114,6 +1113,38 @@ def testTimeBoundsAcrossRollover(self) -> None: with self.assertRaisesRegex(dns.dnssec.ValidationFailure, message): dns.dnssec.validate_rrsig(abs_soa, rrsig, keys, now=now) + def testTimeBoundsAtSerialHalfRange(self) -> None: + key = ed25519.Ed25519PrivateKey.generate() + signer = abs_soa.name + dnskey = dns.dnssec.make_dnskey(key.public_key(), dns.dnssec.Algorithm.ED25519) + keys = {signer: dns.rrset.from_rdata(signer, 300, dnskey)} + half_range = 2**31 + serial_space = 2**32 + now = half_range + 100 + + # RFC 1982 leaves ordering undefined at exactly half the serial space. + # Keep one bound equal to now while testing the other bound. + for inception, expiration, message in ( + (now - half_range + 1, now, None), + (now - half_range, now, "not yet valid"), + (now, now + half_range - 1, None), + (now, now + half_range, "expired"), + ): + with self.subTest(inception=inception, expiration=expiration): + rrsig = dns.dnssec.sign( + abs_soa, + key, + signer, + dnskey, + inception=inception % serial_space, + expiration=expiration % serial_space, + ) + if message is None: + dns.dnssec.validate_rrsig(abs_soa, rrsig, keys, now=now) + else: + with self.assertRaisesRegex(dns.dnssec.ValidationFailure, message): + dns.dnssec.validate_rrsig(abs_soa, rrsig, keys, now=now) + def testOwnerNameMismatch(self): bogus = dns.name.from_text("example.bogus") with self.assertRaises(dns.dnssec.ValidationFailure):