How to Validate DNSSEC in Python From the Root Key Down and Tell a Secure Answer From an Unsigned One and a Forged One
Build a DNSSEC validator in Python that walks the chain of trust from the root key and separates secure, unsigned, and forged answers, then test it against a 15-row matrix of recorded attacks.
When your program asks DNS for the address of a name, the answer is an ordinary message, and anyone who can sit on the path between you and the server can rewrite it. DNSSEC (the DNS Security Extensions) closes that hole with digital signatures. In the words of RFC 4033, “The DNS security extensions provide origin authentication and integrity protection for DNS data, as well as a means of public key distribution. These extensions do not provide confidentiality.” The signatures only help if somebody actually checks them, and that somebody is usually a recursive resolver such as your ISP’s or a public one.
Table Of Content
- What you will build, and what you will learn
- DNSSEC in plain language
- Prerequisites
- Step 1: Ask for records together with their signatures
- Step 2: Key tags and DS digests, or how a parent vouches for a child
- The trust anchors
- Step 3: Verify one signature by hand
- Reading the first 18 bytes
- Step 4: Check the clock and the signature’s other claims
- Step 5: Walk the chain of trust from the root
- Step 6: Tell an unsigned zone from a stripped one
- NSEC and NSEC3 in two paragraphs
- Step 7: A command-line checker you can use in scripts
- Step 8: Compare your verdicts with a validating resolver on 25 real domains
- Step 9: Record the traffic, then attack it
- Step 10: Tests, a second opinion, and planted bugs
- A second opinion from dnspython
- Do the tests notice a bug? Plant twelve
- Common mistakes and gotchas
- Trusting the AD flag
- Treating a missing DS record as proof of an unsigned zone
- Using the wrong TTL, case, or order
- Believing a key tag
- A wrong clock
- Doing unbounded work for NSEC3
- What this validator does not do
- Confirm the whole thing works end to end
- Where to go next
- Sources
In this tutorial you will be that somebody. You will write a DNSSEC validator in Python: a program that takes a domain name, asks any resolver for the records and their signatures, and checks every signature itself, starting from a trusted copy of the root zone’s key and ending at the answer. At the end it prints one of four verdicts, SECURE, INSECURE, BOGUS, or INDETERMINATE, and you will know exactly what each one means and which check produced it. It is the natural sequel to the DNS resolver tutorial and to the root key rollover article, and you can follow it without having read either.
Why write your own when real resolvers already validate? Because the logic is small enough to read in one sitting, and reading it removes the mystery. RFC 4033 describes two kinds of stub resolver, and the difference is the whole point of this tutorial: they may be “validating” or “non-validating”, “depending on whether the stub resolver attempts to verify DNSSEC signatures on its own or trusts a friendly security-aware name server to do so”. A program that merely trusts the resolver’s authenticated data flag is the second kind. The program you build here is the first kind.
What you will build, and what you will learn
- Raw material. You will fetch records and their signatures and read every field of a signature.
- The building blocks. Key tags, DS digests, the exact bytes a signature covers, signature checks for RSA, ECDSA, and Ed25519 keys, and the signature’s validity window.
- The chain of trust. A walk from the root key down through each delegation to the answer, including an answer that is an alias into another zone.
- Proofs of absence. How a parent proves, with a signature, that a child zone is unsigned, and why a validator that skips that proof can be tricked into accepting forged data.
- Evidence. A command-line checker with distinct exit codes, a comparison against a validating resolver on 25 real domains, a 15-row matrix of recorded attacks and controls, 31 tests, a cross-check against dnspython, and 12 deliberately planted bugs to see whether the tests notice.
Everything here is read-only against public DNS servers. The attacks are edits to recorded replies on your own disk, and nothing hostile is ever sent to a live server.
DNSSEC in plain language
DNS stores facts as records. All records of one type at one name form an RRset (for example, the two A records of example.com). A zone is a part of the name tree run by one party, such as com. or example.com.. DNSSEC adds four things on top, and once you know them the rest is bookkeeping:
- DNSKEY records hold a zone’s public keys. By convention a zone has a zone signing key (ZSK) that signs everyday data and a key signing key (KSK) that signs only the DNSKEY set. In a DNSKEY record the flags value 256 marks a zone key and 257 marks a zone key that is also flagged as a secure entry point, which is how a KSK is usually marked.
- RRSIG records are signatures. Each one covers one whole RRset, names the signer zone and the key that made it, and carries a validity window.
- DS records live in the parent zone and hold a fingerprint of the child’s KSK. This is how trust passes down:
com.vouches forexample.com.by publishing and signing a DS record. - NSEC and NSEC3 records are signed statements about what does not exist, so that an attacker cannot make a record vanish.
Trust has to start somewhere. The root zone has no parent, so its key is trusted because you were handed it by a source you trust. That fingerprint is called a trust anchor. From there the logic is a chain:
the root zone "." you already trust its key (the trust anchor)
| signs a DS record: "the key of com. has this fingerprint"
com. its DNSKEY set must contain a key with that fingerprint
| signs a DS record: "the key of example.com. has this fingerprint"
example.com. its DNSKEY set must contain a key with that fingerprint
| signs the A record
example.com. A ... the answer you can now believe
Every validation ends in one of four verdicts, and keeping them apart is the skill this tutorial teaches:
- SECURE: every link from the trust anchor to the answer was verified.
- INSECURE: a parent proved, with a signature, that the child zone is not signed. Nothing is wrong, and nothing is protected either. Many well-known domains are in this state, as the survey in Step 8 shows.
- BOGUS: something is signed, or must be signed, and a check failed. Throw the data away.
- INDETERMINATE: this validator cannot decide, for example because of an algorithm it does not implement. It must not claim security it did not verify.
Prerequisites
- Python. I ran everything on Python 3.13.14 on Windows 11. The pinned dnspython 2.9.2 needs Python 3.11 or newer, so any version from 3.11 up should work, but I only tested 3.13.14.
- Packages.
dnspython2.9.2 reads and writes DNS messages and produces the canonical form of record data.cryptography50.0.2 checks the signatures.pytest9.2.0 runs the tests. All of the DNSSEC logic is yours. - Network. Outbound UDP and TCP on port 53 to a public resolver. The code uses
1.1.1.1; changeRESOLVERin Step 1 if your network blocks it. Steps 1 to 8 and the capture in Step 9 need the network; everything after the capture runs offline. - Basic Python. Functions, classes, and dictionaries. DNS terms are explained as they appear.
Set up a folder and a virtual environment, then install the packages:
mkdir dnssec-lab
cd dnssec-lab
python -m venv .venv
# Windows: .venv\Scripts\activate macOS or Linux: source .venv/bin/activate
pip install dnspython==2.9.2 cryptography==50.0.2 pytest==9.2.0
Every file below goes in that one folder. Each code block starts with a comment naming its file, and blocks marked (continued) are appended to the file that is already there. The blocks follow the order of the steps, so you can build the validator up one piece at a time and run a script after each step.
Step 1: Ask for records together with their signatures
A normal DNS query returns records and no signatures. To get signatures you set a flag called DO (short for DNSSEC OK) in the query’s EDNS section. We set a second flag, CD (short for Checking Disabled), which tells a validating resolver to hand over the data even if its own validation fails. That sounds backwards until you remember what we are doing: the resolver is only a messenger, and we do the checking. RFC 4035 puts it this way about the AD bit: “The AD bit is controlled by name servers; a security-aware name server MUST ignore the setting of the AD bit in queries.” The AD bit means the resolver says it validated, and it is exactly the thing we refuse to rely on.
Create dnsq.py. For now it holds only ask(); the record-and-replay helpers arrive in Step 9.
# dnsq.py
"""Ask a recursive resolver for records, and record or replay the replies."""
import json
import dns.flags
import dns.message
import dns.name
import dns.query
import dns.rdatatype
RESOLVER = "1.1.1.1"
def ask(name, rdtype, server=RESOLVER):
"""Send a query with DO (DNSSEC OK) so RRSIG records come back, and CD (Checking Disabled)
so the resolver hands over even data it considers bogus. We do the checking ourselves."""
query = dns.message.make_query(name, rdtype, want_dnssec=True, use_edns=0, payload=1232)
query.flags |= dns.flags.CD
response, _used_tcp = dns.query.udp_with_fallback(query, server, timeout=5)
return response
The function sends the query over UDP with a 1,232-byte buffer and, through udp_with_fallback, retries over TCP when the answer is too big for one datagram. Now look at what comes back for example.com. step01_look.py also imports when(), a small time formatter, so first create dnssec.py with its foundation: imports, two exception types, and three helpers that every later step uses.
# dnssec.py
"""A small DNSSEC validator. It answers SECURE, INSECURE, BOGUS or INDETERMINATE."""
import base64
import datetime
import hashlib
import struct
from dataclasses import dataclass, field
import dns.name
import dns.rcode
import dns.rdatatype as rt
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa, utils
SECURE, INSECURE, BOGUS, INDETERMINATE = "SECURE", "INSECURE", "BOGUS", "INDETERMINATE"
ROOT = dns.name.root
ZONE_KEY, REVOKED = 0x0100, 0x0080 # DNSKEY flag bits (RFC 4034, RFC 5011)
MAX_NSEC3_ITERATIONS = 100 # refuse absurd hashing work (RFC 9276)
class Bogus(Exception):
"""The data is signed, or must be, and the check failed. Never use the data."""
class Indeterminate(Exception):
"""We cannot decide: an unsupported algorithm, or an answer shape this validator does not handle."""
# ---- Three small helpers that every step uses -------------------------------------
def when(seconds):
return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M")
def collect(section, rdtype):
"""Group one record type in a message section: {owner: (rdatas, rrsigs)}."""
out = {}
for rrset in section:
if rrset.rdtype == rdtype:
out.setdefault(rrset.name, ([], []))[0].extend(rrset)
elif rrset.rdtype == rt.RRSIG and rrset.covers == rdtype:
out.setdefault(rrset.name, ([], []))[1].extend(rrset)
return out
def get(section, name, rdtype):
group = collect(section, rdtype).get(name, ([], []))
return group[0], group[1]
collect() groups the records of one type in one section of a reply and pairs them with the RRSIG records that cover them, and get() picks out one name. The two exceptions are the two ways a check can end badly: Bogus for data that fails a check it must pass, and Indeterminate for cases this validator does not handle. Now the script:
# step01_look.py
import dns.rdatatype as rt
from dnsq import ask
from dnssec import when
reply = ask("example.com", "A")
for rrset in reply.answer:
if rrset.rdtype == rt.A:
for record in rrset:
print(f"A record : {rrset.name} TTL {rrset.ttl} -> {record.address}")
elif rrset.rdtype == rt.RRSIG:
for sig in rrset:
print(f"RRSIG covers : {rt.to_text(sig.type_covered)}")
print(f" algorithm : {int(sig.algorithm)} ({sig.algorithm.name})")
print(f" labels : {sig.labels}")
print(f" original TTL: {sig.original_ttl}")
print(f" inception : {when(sig.inception)} UTC")
print(f" expiration : {when(sig.expiration)} UTC")
print(f" key tag : {sig.key_tag}")
print(f" signer : {sig.signer}")
print(f" signature : {len(sig.signature)} bytes")
Run python step01_look.py:
A record : example.com. TTL 23 -> 172.66.147.243
A record : example.com. TTL 23 -> 104.20.23.154
RRSIG covers : A
algorithm : 13 (ECDSAP256SHA256)
labels : 2
original TTL: 300
inception : 2026-11-08 19:34 UTC
expiration : 2026-11-10 21:34 UTC
key tag : 34505
signer : example.com.
signature : 64 bytes
Here is what the fields mean. The two A records carry a TTL of 135, which is what is left of the cache time at the resolver, but the signature says original TTL: 300. The signer signed the record as published, with 300, and the validator must use that number, not the cached one. Labels is 2 because example.com has two labels. Algorithm 13 is ECDSA over the P-256 curve with SHA-256. The key tag (34505) is a hint about which DNSKEY made the signature, and the signer names the zone that owns the key. The signature is 64 bytes: two 32-byte numbers.
Gotcha: if you see the A records but no RRSIG lines, something between you and the resolver stripped the DNSSEC data, or the DO flag was lost. Try another resolver, or force TCP.
Step 2: Key tags and DS digests, or how a parent vouches for a child
A signature names its key by a 16-bit key tag. The tag is a simple checksum over the DNSKEY record (RFC 4034, Appendix B), and it is a hint, not an identity: two different keys can share a tag. RFC 4035 spells out what a validator must do about it: “It is possible for more than one DNSKEY RR to match the conditions above. In this case, the validator cannot predetermine which DNSKEY RR to use to authenticate the signature, and it MUST try each matching DNSKEY RR until either the signature is validated or the validator has run out of matching public keys to try.” We will honor that rule in Step 4.
The DS record is how a parent vouches for a child. It holds the child key’s tag, its algorithm, a digest type, and the digest itself, computed as digest = digest_algorithm( DNSKEY owner name | DNSKEY RDATA). RFC 4035 defines when a DNSKEY matches a DS: “The Algorithm and Key Tag in the DS RR match the Algorithm field and the key tag of a DNSKEY RR in the child zone’s apex DNSKEY RRset, and, when the DNSKEY RR’s owner name and RDATA are hashed using the digest algorithm specified in the DS RR’s Digest Type field, the resulting digest value matches the Digest field of the DS RR.” Append this to dnssec.py:
# dnssec.py (continued)
# ---- Step 2: key tags and DS digests --------------------------------------------------
def key_tag(dnskey):
"""RFC 4034 Appendix B: a 16-bit checksum of the DNSKEY RDATA. It is a hint, not an identity."""
total = 0
for i, byte in enumerate(dnskey.to_wire()):
total += byte if i % 2 else byte << 8
total += (total >> 16) & 0xFFFF
return total & 0xFFFF
DIGESTS = {1: hashlib.sha1, 2: hashlib.sha256, 4: hashlib.sha384} # DS digest types: SHA-1 (validate only), SHA-256, SHA-384
ALGORITHMS = {8, 10, 13, 14, 15} # RSA/SHA-256, RSA/SHA-512, ECDSA P-256, ECDSA P-384, Ed25519
def ds_digest(owner, dnskey, digest_type):
"""digest = HASH(canonical owner name | DNSKEY RDATA), RFC 4034 section 5.1.4."""
return DIGESTS[digest_type](owner.canonicalize().to_wire() + dnskey.to_wire()).digest()
def matches_ds(owner, dnskey, ds):
return (ds.digest_type in DIGESTS and int(ds.algorithm) == int(dnskey.algorithm)
and ds.key_tag == key_tag(dnskey) and ds_digest(owner, dnskey, ds.digest_type) == ds.digest)
Three notes on the code. The checksum adds up the bytes of the DNSKEY RDATA, alternating between the high and low half of a 16-bit number, then folds the carry back in. DIGESTS lists the DS digest types we accept: SHA-1 (type 1), SHA-256 (type 2), and SHA-384 (type 4). RFC 8624 says “SHA-1 is still widely used for Delegation Signer (DS) records, so validators MUST implement validation, but it MUST NOT be used to generate new DS and CDS records”, so we validate it and nothing here ever creates one. ALGORITHMS lists the signature algorithms we implement; we come back to the gaps at the end.
The trust anchors
The root zone’s trust anchors are published by IANA in root-anchors.xml, as DS-style fingerprints. Put the ones that are valid today in anchors.py:
# anchors.py
"""The root trust anchors, in DS form, copied from IANA's root-anchors.xml on 2026-11-09."""
import dns.rdata
import dns.rdataclass
import dns.rdatatype
ROOT_ANCHORS = [
dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS,
"20326 8 2 E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D"),
dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS,
"38696 8 2 683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16"),
]
This file is the one thing in the whole system you must get right out of band. If an attacker can change it, every verdict afterwards is meaningless. Take it from IANA over HTTPS, compare it with a second source such as your operating system’s DNS package, and also check the detached signature IANA publishes next to the XML, root-anchors.p7s. I confirmed that file exists but did not verify the signature for this article. The next script at least proves our copy matches IANA’s current XML, and shows the root’s keys:
# step02_keys.py
import re
import urllib.request
import dns.dnssec
import dns.rdatatype as rt
from anchors import ROOT_ANCHORS
from dnsq import ask
from dnssec import ROOT, ds_digest, get, key_tag
keys, _sigs = get(ask(".", "DNSKEY").answer, ROOT, rt.DNSKEY)
for k in keys:
role = "KSK" if k.flags & 1 else "ZSK"
mine, theirs = key_tag(k), dns.dnssec.key_id(k)
digest = ds_digest(ROOT, k, 2).hex().upper()
anchor = any(a.key_tag == mine and a.digest.hex().upper() == digest for a in ROOT_ANCHORS)
print(f"flags {k.flags} {role} algorithm {int(k.algorithm)} key tag {mine} (dnspython: {theirs}) "
f"SHA-256 digest {digest[:16]}... {'= trust anchor' if anchor else 'not an anchor'}")
# Our copy of the anchors must equal what IANA publishes, so check it instead of trusting it.
xml = urllib.request.urlopen("https://data.iana.org/root-anchors/root-anchors.xml", timeout=20).read().decode()
published = {int(tag): digest for tag, digest in re.findall(r"<KeyTag>(\d+)</KeyTag>.*?<Digest>([0-9A-F]+)</Digest>", xml, re.S)}
print("IANA publishes digests for key tags:", sorted(published))
print("our anchors equal IANA's:", all(published.get(a.key_tag) == a.digest.hex().upper() for a in ROOT_ANCHORS))
Run python step02_keys.py:
flags 256 ZSK algorithm 8 key tag 8763 (dnspython: 8763) SHA-256 digest 7C0B2D15671A4ADE... not an anchor
flags 257 KSK algorithm 8 key tag 20326 (dnspython: 20326) SHA-256 digest E06D44B80B8F1D39... = trust anchor
flags 257 KSK algorithm 8 key tag 38696 (dnspython: 38696) SHA-256 digest 683D2D0ACB8C9B71... = trust anchor
IANA publishes digests for key tags: [19036, 20326, 38696]
our anchors equal IANA's: True
The root DNSKEY set held three keys on the day I ran this: key 8763, a zone signing key, and two key signing keys, 20326 and 38696. Our hand-written key tags agree with dnspython’s on all three, and the SHA-256 digests of the two KSKs equal the two anchors. IANA’s file also lists a third fingerprint, tag 19036, which is marked valid only until 2019-01-11, so it is rightly absent from our file. Why two live anchors? The root KSK is being replaced, a process the root key rollover article covers in detail. For this tutorial it means a validator must carry both anchors and accept either one.
Step 3: Verify one signature by hand
This is the heart of DNSSEC. An RRSIG does not sign the records as they appear on the wire; it signs a carefully defined byte string, so that signer and verifier compute the same bytes. RFC 4034 says the signature covers “the wire format of the RRSIG RDATA fields with the Signer’s Name field in canonical form and the Signature field excluded”, followed by the RRset in canonical form. In practice that means:
- The RRSIG’s own fields except the signature: type covered, algorithm, labels, original TTL, expiration, inception, key tag, then the signer name in lowercase wire format.
- Each record of the RRset as: lowercase owner name, type, class, original TTL, data length, and the data in canonical form.
- The records sorted by their canonical data, with duplicates removed. The order the server happened to send them in must not matter.
Then a key and an algorithm turn those bytes into a yes or no. Append this to dnssec.py:
# dnssec.py (continued)
# ---- Step 3: the bytes a signature covers, and the check itself -----------------------
def owner_labels(name):
count = len(name) - 1 # the root label does not count
return count - 1 if count and name.labels[0] == b"*" else count
def signed_data(sig, name, rdtype, rdatas):
"""RFC 4034 section 3.1.8.1: the RRSIG fields without the signature, then the RRset in canonical form."""
head = struct.pack("!HBBIIIH", sig.type_covered, int(sig.algorithm), sig.labels, sig.original_ttl,
sig.expiration, sig.inception, sig.key_tag) + sig.signer.canonicalize().to_wire()
owner = name.canonicalize().to_wire()
body = b""
for wire in sorted({rd.to_digestable() for rd in rdatas}): # RFC 4034 section 6.3: sort, drop duplicates
body += owner + struct.pack("!HHIH", rdtype, 1, sig.original_ttl, len(wire)) + wire # class IN = 1
return head + body
def public_key(dnskey):
alg, k = int(dnskey.algorithm), dnskey.key
if alg in (8, 10): # RFC 3110: exponent length, exponent, modulus
length, offset = (k[0], 1) if k[0] else (int.from_bytes(k[1:3], "big"), 3)
exponent = int.from_bytes(k[offset:offset + length], "big")
return rsa.RSAPublicNumbers(exponent, int.from_bytes(k[offset + length:], "big")).public_key()
if alg in (13, 14): # RFC 6605: the point as x then y
size, curve = (32, ec.SECP256R1()) if alg == 13 else (48, ec.SECP384R1())
return ec.EllipticCurvePublicNumbers(int.from_bytes(k[:size], "big"), int.from_bytes(k[size:], "big"), curve).public_key()
if alg == 15: # RFC 8080
return ed25519.Ed25519PublicKey.from_public_bytes(k)
raise Indeterminate(f"unsupported DNSSEC algorithm {alg}")
def check_signature(dnskey, sig, data):
alg, raw = int(sig.algorithm), sig.signature
try:
key = public_key(dnskey)
if alg == 8:
key.verify(raw, data, padding.PKCS1v15(), hashes.SHA256())
elif alg == 10:
key.verify(raw, data, padding.PKCS1v15(), hashes.SHA512())
elif alg in (13, 14):
half = 32 if alg == 13 else 48
if len(raw) != 2 * half:
return False
der = utils.encode_dss_signature(int.from_bytes(raw[:half], "big"), int.from_bytes(raw[half:], "big"))
key.verify(der, data, ec.ECDSA(hashes.SHA256() if alg == 13 else hashes.SHA384()))
else:
key.verify(raw, data)
except (InvalidSignature, ValueError):
return False
return True
One piece is borrowed: rd.to_digestable() is dnspython’s canonical form for the data of one record, which saves us from lowercasing the names inside every record type by hand. Everything around it is ours. The algorithm switch follows RFC 8624, which marks RSASHA256 (8) and ECDSAP256SHA256 (13) as MUST for both signing and validation, and ECDSAP384SHA384 (14), ED25519 (15), and ED448 (16) as RECOMMENDED for validation. Two details trip people up. An RSA public key is stored as the exponent length, the exponent, and then the modulus, and an ECDSA signature is the two numbers r and s side by side, not the DER encoding that cryptography libraries expect, so check_signature() converts it.
Now verify the example.com signature from Step 1. This trusts the DNSKEY it fetches for the moment; the chain of trust comes in Step 5.
# step03_verify_one.py
import dns.dnssec
import dns.name
import dns.rdataclass
import dns.rdataset
import dns.rdatatype as rt
from dnsq import ask
from dnssec import check_signature, get, key_tag, signed_data
name = dns.name.from_text("example.com")
answer, key_reply = ask(name, "A"), ask(name, "DNSKEY")
rdatas, sigs = get(answer.answer, name, rt.A)
keys, _ = get(key_reply.answer, name, rt.DNSKEY)
sig = sigs[0]
key = next(k for k in keys if key_tag(k) == sig.key_tag and int(k.algorithm) == int(sig.algorithm))
data = signed_data(sig, name, rt.A, rdatas)
print(f"signed data : {len(data)} bytes, starts with {data[:18].hex()}")
print(f"signature : algorithm {int(sig.algorithm)}, made by key tag {sig.key_tag}")
print("verifies :", check_signature(key, sig, data))
damaged = bytearray(data)
damaged[-1] ^= 1 # flip one bit of the address
print("one bit off :", check_signature(key, sig, bytes(damaged)))
# The same RRset through dnspython's own validator, as a second opinion.
rrset = answer.find_rrset(answer.answer, name, dns.rdataclass.IN, rt.A)
rrsigs = answer.find_rrset(answer.answer, name, dns.rdataclass.IN, rt.RRSIG, rt.A)
try:
dns.dnssec.validate(rrset, rrsigs, {name: dns.rdataset.from_rdata_list(3600, keys)})
print("dnspython : accepts")
except dns.dnssec.ValidationFailure as exc:
print("dnspython : rejects", exc)
Run python step03_verify_one.py:
signed data : 85 bytes, starts with 00010d020000012c6af38e1e6af0cefe86c9
signature : algorithm 13, made by key tag 34505
verifies : True
one bit off : False
dnspython : accepts
Reading the first 18 bytes
Those 18 bytes are the RRSIG fields, in order: 0001 is type A, 0d is algorithm 13, 02 is two labels, 0000012c is the original TTL of 300, the next two groups of four bytes are the expiration and inception times, and 86c9 is the key tag, 34505 in decimal. The whole signed message is 85 bytes: 18 for those fields, 13 for the signer name, and 27 for each of the two A records. The signature verifies, flipping a single bit makes it fail, and dnspython’s own validator agrees. That last line matters later, because we will use dnspython as a second opinion on everything.
Common mistakes here: using the cached TTL instead of the original one, forgetting to lowercase the owner name, and signing records in arrival order. Each makes a genuine signature look forged, and each is a bug I would expect in a first attempt.
Step 4: Check the clock and the signature’s other claims
A signature that is mathematically correct can still be unacceptable. The RRSIG carries an inception time and an expiration time, both 32-bit counts of seconds since 1970. RFC 4034 notes that “The longest interval that can be expressed by this format without wrapping is approximately 136 years”, and so requires serial number arithmetic for every comparison: “the values contained in these fields cannot refer to dates more than 68 years in either the past or the future”. A plain inception <= now <= expiration breaks when a window straddles the wrap-around, and the demonstration below shows it.
verify_rrset() is the function that decides whether one RRset is genuinely signed by one zone. It rejects a signature unless all of these hold, each checked against a rule in RFC 4035 section 5.3.1:
- The signature covers the type we are checking.
- “The RRSIG Signer’s Name field is equal to the name of the zone containing the RRset.” A zone may only sign its own names.
- “The number of labels in the RRset owner name MUST be greater than or equal to the value in the RRSIG RR’s Labels field.” If the owner has more labels than the field says, the answer may have come from a wildcard, and RFC 4035 warns that “the RRset is either invalid or the result of wildcard expansion. The resolver MUST verify that wildcard expansion was applied properly before considering the RRset to be authentic.” Proving that needs the non-existence logic we do not build, so our validator answers
INDETERMINATEinstead of guessing. - The clock is inside the validity window.
- Some key with a matching algorithm and key tag verifies the signature, and we try every such key because of the collision rule from Step 2. A key must also carry the zone-key flag and must not carry the revoked flag.
Append this to dnssec.py:
# dnssec.py (continued)
# ---- Step 4: the validity window ------------------------------------------------------
def serial_ge(a, b):
"""RFC 1982 serial arithmetic on 32-bit numbers: is a at or after b? Correct across the 2106 wrap-around."""
return ((a - b) & 0xFFFFFFFF) < 0x80000000
def in_window(sig, now):
now32 = int(now) & 0xFFFFFFFF
return serial_ge(now32, sig.inception) and serial_ge(sig.expiration, now32)
def verify_rrset(name, rdtype, rdatas, sigs, zone, keys, now):
"""Return the RRSIG that verifies this RRset with a key of `zone`, or raise Bogus with the reasons."""
why = []
for sig in sigs:
if sig.type_covered != rdtype:
continue
if sig.signer != zone:
why.append(f"signer {sig.signer} is not {zone}")
elif sig.labels > owner_labels(name):
why.append("labels field is larger than the owner name")
elif sig.labels < owner_labels(name):
raise Indeterminate("wildcard expansion: it needs a proof that the name does not exist, which this validator lacks")
elif not in_window(sig, now):
why.append(f"signature outside its validity window ({when(sig.inception)} to {when(sig.expiration)} UTC)")
else:
data = signed_data(sig, name, rdtype, rdatas)
for key in keys: # key tags collide, so try every key that fits
usable = key.flags & ZONE_KEY and not key.flags & REVOKED and key.protocol == 3
if usable and int(key.algorithm) == int(sig.algorithm) and key_tag(key) == sig.key_tag:
if check_signature(key, sig, data):
return sig
why.append(f"signature made with key {sig.key_tag} does not verify")
if not why:
why.append(f"no key {sig.key_tag} (algorithm {int(sig.algorithm)}) in the DNSKEY set of {zone}")
raise Bogus(f"{name} {rt.to_text(rdtype)}: " + ("; ".join(why) if why else "no RRSIG"))
The function returns the RRSIG that verified, so callers can log when it expires, or it raises Bogus with every reason it collected. Now look at real windows and at the wrap-around:
# step04_time.py
import time
from types import SimpleNamespace
import dns.rdatatype as rt
from dnsq import ask
from dnssec import in_window, when
now = time.time()
for name, rdtype in [(".", "DNSKEY"), ("example.com", "A"), ("iana.org", "A"), ("nlnetlabs.nl", "A")]:
for rrset in ask(name, rdtype).answer:
if rrset.rdtype == rt.RRSIG:
for sig in rrset:
print(f"{str(rrset.name):13} {rt.to_text(sig.type_covered):6} {when(sig.inception)} to {when(sig.expiration)} UTC: "
f"lifetime {(sig.expiration - sig.inception) / 86400:4.1f} days, {(sig.expiration - now) / 86400:4.1f} left")
print()
print("A window that straddles the 32-bit wrap-around (inception 0xFFFFFF00, expiration 0x00000100):")
wrapping = SimpleNamespace(inception=0xFFFFFF00, expiration=0x00000100)
for clock in (0xFFFFFE00, 0xFFFFFF80, 0x00000080, 0x00000200):
naive = wrapping.inception <= clock <= wrapping.expiration
print(f" clock {clock:#010x}: naive comparison {str(naive):5} serial arithmetic {in_window(wrapping, clock)}")
Run python step04_time.py:
. DNSKEY 2026-10-31 00:00 to 2026-11-21 00:00 UTC: lifetime 21.0 days, 11.1 left
example.com. A 2026-11-08 19:38 to 2026-11-10 21:38 UTC: lifetime 2.1 days, 1.0 left
iana.org. A 2026-11-09 15:33 to 2026-12-01 00:26 UTC: lifetime 21.4 days, 21.2 left
nlnetlabs.nl. A 2026-11-09 01:50 to 2026-12-07 01:50 UTC: lifetime 28.0 days, 27.2 left
A window that straddles the 32-bit wrap-around (inception 0xFFFFFF00, expiration 0x00000100):
clock 0xfffffe00: naive comparison False serial arithmetic False
clock 0xffffff80: naive comparison False serial arithmetic True
clock 0x00000080: naive comparison False serial arithmetic True
clock 0x00000200: naive comparison False serial arithmetic False
Signature lifetimes differ a lot. The root’s DNSKEY signature lasts 21 days, while the example.com address record was signed for about two days, so a validator whose clock runs a day or two fast rejects perfectly good data from that zone. Keep your clock synchronized. In the wrap-around demonstration the naive comparison is wrong in two of the four rows, and serial arithmetic is right in all four. The year-2106 wrap is far away, but RFC 4034 makes the arithmetic mandatory, and a validator that skips it fails exactly when the specification says it should work.
Step 5: Walk the chain of trust from the root
You now have every piece. The walk works like this:
- Fetch the root DNSKEY set. Trust it only if a key that matches a trust anchor signed it.
- Fetch the answer. Its RRSIG names a signer, the zone that vouches for it.
- For each name from just below the root down to that signer (
com., thenexample.com.), ask the parent for the child’s DS records, verify their signature with the parent’s keys, then fetch the child’s DNSKEY set and require that a key matching one of those DS records signed it. - Verify the answer itself with the keys of the signer zone.
If the answer is an alias (a CNAME), the alias record is validated first and then the walk starts over for its target, which may live in a different zone. First the helpers, appended to dnssec.py:
# dnssec.py (continued)
# ---- Step 5: walking the chain of trust ------------------------------------------------
def suffixes(name):
"""com., example.com., www.example.com.: every name from just below the root down to `name`."""
return [dns.name.Name(name.labels[-k:]) for k in range(2, len(name.labels) + 1)]
@dataclass
class Result:
status: str
reason: str
trail: list = field(default_factory=list)
queries: int = 0
expiries: list = field(default_factory=list) # (what, expiration time, algorithm) for every signature that verified
suffixes() lists the names to visit, top down. Result carries the verdict, the reason, a trail of human-readable steps, a count of queries, and the expiry time of every signature that verified. Now the validator class, in four pieces. The first holds its state and the two checks that anchor the chain:
# dnssec.py (continued)
class Validator:
def __init__(self, fetch, anchors, now):
self.fetch, self.anchors, self.now = fetch, anchors, now
self.keys, self.trail, self.queries, self.expiries = {}, [], 0, []
def ask(self, name, rdtype):
self.queries += 1
return self.fetch(name, rdtype)
def accept(self, sig, what, text):
self.expiries.append((what, sig.expiration, int(sig.algorithm)))
self.trail.append(f"{what}: {text} (signature expires {when(sig.expiration)} UTC)")
def authenticate_zone(self, zone, ds_set):
"""Fetch the DNSKEY RRset of `zone`. It is trusted only if a key that matches a DS record signed it."""
msg = self.ask(zone, rt.DNSKEY)
keys, sigs = get(msg.answer, zone, rt.DNSKEY)
usable = [ds for ds in ds_set if ds.digest_type in DIGESTS and int(ds.algorithm) in ALGORITHMS]
if not usable:
raise Indeterminate(f"no DS record for {zone} uses an algorithm and digest type we support")
entry = [k for k in keys if k.protocol == 3 and k.flags & ZONE_KEY and not k.flags & REVOKED
and any(matches_ds(zone, k, ds) for ds in usable)]
if not entry:
raise Bogus(f"no DNSKEY of {zone} matches any of its {len(usable)} DS records")
sig = verify_rrset(zone, rt.DNSKEY, keys, sigs, zone, entry, self.now)
self.keys[zone] = keys
self.accept(sig, f"{zone} DNSKEY", f"{len(keys)} keys, set signed by key {sig.key_tag}, which the parent vouches for")
def delegation(self, zone, child):
"""Ask the parent about `child`: a signed DS RRset, or a signed proof that there is none."""
msg = self.ask(child, rt.DS)
if msg.rcode() != dns.rcode.NOERROR:
raise Indeterminate(f"{child} DS: {dns.rcode.to_text(msg.rcode())}")
ds, sigs = get(msg.answer, child, rt.DS)
if ds:
sig = verify_rrset(child, rt.DS, ds, sigs, zone, self.keys[zone], self.now)
self.accept(sig, f"{child} DS", f"signed by {zone} key {sig.key_tag}")
return "secure", ds
kind, why = prove_no_ds(msg, child, zone, self.keys[zone], self.now)
self.trail.append(f"{child} DS: absent, and that is proven: {why}")
return kind, why
authenticate_zone() is the rule from Step 2 in code: a DNSKEY set is trusted only if a key that matches a DS record (or, for the root, a trust anchor) signed the set. delegation() asks the parent about a child and returns one of three things: a verified DS RRset, a proven absence of one, or a failure. It calls prove_no_ds(), which you write in Step 6. The names in this step all have DS records at every cut, so that function does not run yet. The next piece walks one RRset down the chain:
# dnssec.py (continued)
def check_rrset(self, name, rdtype, rdatas, sigs):
signer = sigs[0].signer if sigs else None
if signer is not None and not name.is_subdomain(signer):
raise Bogus(f"{signer} signed {name}, which is outside {signer}")
zone = ROOT
for child in suffixes(signer if signer is not None else name):
kind, detail = self.delegation(zone, child)
if kind == "secure":
self.authenticate_zone(child, detail)
zone = child
elif kind == "insecure":
return INSECURE, detail
if signer is None:
raise Bogus(f"{name} {rt.to_text(rdtype)} is unsigned, {zone} is a signed zone, and no unsigned delegation was proven")
if zone != signer:
raise Bogus(f"{name} is signed by {signer}, which is not a signed zone below {zone}")
sig = verify_rrset(name, rdtype, rdatas, sigs, zone, self.keys[zone], self.now)
self.accept(sig, f"{name} {rt.to_text(rdtype)}", f"signed by {zone} key {sig.key_tag}")
return SECURE, f"{name} {rt.to_text(rdtype)} is signed by {zone}, and the chain of trust reaches the root"
Notice two defensive choices. The walk goes down to the signer named in the signature, and anything that does not fit is an error: a signer outside the owner’s tree, an unsigned answer inside a signed zone, or a signer that is not a zone cut. And the unsigned case is not automatically fine. An answer with no signature becomes INSECURE only if the walk proves that some delegation on the way down has no DS record; otherwise it is BOGUS. The last piece is the entry point:
# dnssec.py (continued)
def validate(self, qname, qtype="A"):
try:
status, reason = self._validate(dns.name.from_text(qname), rt.from_text(qtype), 0)
except Bogus as exc:
status, reason = BOGUS, str(exc)
except Indeterminate as exc:
status, reason = INDETERMINATE, str(exc)
return Result(status, reason, self.trail, self.queries, self.expiries)
def _validate(self, qname, qtype, depth):
if ROOT not in self.keys:
self.authenticate_zone(ROOT, self.anchors)
msg = self.ask(qname, qtype)
if msg.rcode() != dns.rcode.NOERROR:
raise Indeterminate(f"{dns.rcode.to_text(msg.rcode())}: proving that a name does not exist is not implemented here")
rdtype = qtype
rdatas, sigs = get(msg.answer, qname, qtype)
if not rdatas and qtype != rt.CNAME:
rdtype = rt.CNAME
rdatas, sigs = get(msg.answer, qname, rt.CNAME)
if not rdatas:
raise Indeterminate("empty answer (NODATA): proving it needs the NSEC3 logic of step 6 applied to the answer itself")
status, reason = self.check_rrset(qname, rdtype, rdatas, sigs)
if rdtype == rt.CNAME and qtype != rt.CNAME and status == SECURE:
if depth >= 8:
raise Indeterminate("CNAME chain longer than 8")
self.trail.append(f"{qname} is a CNAME for {rdatas[0].target}; following it")
return self._validate(rdatas[0].target, qtype, depth + 1)
return status, reason
Everything that could be wrong raises one of the two exceptions, and validate() turns them into verdicts. The code does not prove that a name or a record type does not exist, so an NXDOMAIN or an empty answer ends as INDETERMINATE rather than as a guess. Try it on a name that is an alias into another zone:
# step05_chain.py
import time
from anchors import ROOT_ANCHORS
from dnsq import ask
from dnssec import Validator
validator = Validator(ask, ROOT_ANCHORS, time.time())
result = validator.validate("www.iana.org", "A")
print(f"{result.status}: {result.reason}")
print(f"{result.queries} queries")
for line in result.trail:
print(" " + line)
Run python step05_chain.py:
SECURE: www.iana.org.cdn.cloudflare.net. A is signed by cloudflare.net., and the chain of trust reaches the root
11 queries
. DNSKEY: 3 keys, set signed by key 38696, which the parent vouches for (signature expires 2026-11-21 00:00 UTC)
org. DS: signed by . key 8763 (signature expires 2026-11-22 05:00 UTC)
org. DNSKEY: 5 keys, set signed by key 26974, which the parent vouches for (signature expires 2026-11-30 15:28 UTC)
iana.org. DS: signed by org. key 25488 (signature expires 2026-11-30 15:28 UTC)
iana.org. DNSKEY: 2 keys, set signed by key 2234, which the parent vouches for (signature expires 2026-11-30 14:30 UTC)
www.iana.org. CNAME: signed by iana.org. key 18221 (signature expires 2026-12-01 00:41 UTC)
www.iana.org. is a CNAME for www.iana.org.cdn.cloudflare.net.; following it
net. DS: signed by . key 8763 (signature expires 2026-11-22 17:00 UTC)
net. DNSKEY: 2 keys, set signed by key 37331, which the parent vouches for (signature expires 2026-11-18 15:10 UTC)
cloudflare.net. DS: signed by net. key 3840 (signature expires 2026-11-16 04:00 UTC)
cloudflare.net. DNSKEY: 2 keys, set signed by key 2371, which the parent vouches for (signature expires 2027-01-06 08:05 UTC)
www.iana.org.cdn.cloudflare.net. A: signed by cloudflare.net. key 34505 (signature expires 2026-11-10 21:38 UTC)
The trail reads like the diagram from the start of the tutorial. The root DNSKEY set is vouched for by anchor 38696. Then org. gets a signed DS record from the root’s zone signing key, its own DNSKEY set is signed by a key that DS names, and so on down to the iana.org. zone. The alias www.iana.org points into cloudflare.net., so the walk restarts there (net., then cloudflare.net.) and ends with the address record. It took 11 queries and 11 verified signatures, and every link carries its own expiry time. Whatever the resolver told us, the verdict is ours.
Step 6: Tell an unsigned zone from a stripped one
Many domains are not signed. When the validator asks the com. zone for the DS record of google.com, the honest answer is there isn’t one, and the validator must then decide: is this zone really unsigned, or did an attacker delete the DS record and its signature to make a signed zone look unsigned? A validator that accepts every missing DS record as proof of an unsigned zone lets an attacker defeat DNSSEC for any signed domain by deleting that one record from the reply. The defense is that the no DS answer must itself be signed. That is what NSEC and NSEC3 records are for.
NSEC and NSEC3 in two paragraphs
An NSEC record lists, for one existing name, the next existing name in the zone and a type bitmap of the record types that exist at the first name. Signed, it proves what is missing. For a delegation, a bitmap that has NS but no DS (and no SOA) proves an unsigned child. Plain NSEC reveals every name in the zone, so many zones use NSEC3, which hashes the names. RFC 5155 defines the hash:
IH(salt, x, 0) = H(x || salt), and
IH(salt, x, k) = H(IH(salt, x, k-1) || salt), if k > 0
hash of a name = IH(salt, owner name in canonical form, iterations)
where H is SHA-1 and the name is in canonical wire format. The records then form a ring of hashes, each pointing at the next hash. A name that is not in the ring is covered by the record whose owner hash is just below it. There is one more twist. Large zones such as com. use opt-out: “Hashed owner names of unsigned delegations may be excluded from the chain.” A flag marks the records whose span may hide such delegations: “The Opt-Out Flag indicates whether this NSEC3 RR may cover unsigned delegations.” So a record with the flag set that covers the hash of a delegation, together with a matching record for the delegation’s parent name (the closest provable encloser, which RFC 5155 defines as “the longest ancestor of a name that can be proven to exist”), proves the delegation is unsigned. Append the helpers to dnssec.py:
# dnssec.py (continued)
# ---- Step 6: proving that a DS record does not exist ----------------------------------
def bitmap_types(windows):
"""Decode the type bitmap of an NSEC or NSEC3 record (RFC 4034 section 4.1.2) into a set of type numbers."""
types = set()
for window, bitmap in windows:
for i, byte in enumerate(bitmap):
for bit in range(8):
if byte & (0x80 >> bit):
types.add(window * 256 + i * 8 + bit)
return types
def nsec3_hash(name, salt, iterations):
"""RFC 5155 section 5: SHA-1 over the canonical name plus salt, repeated `iterations` more times."""
digest = hashlib.sha1(name.canonicalize().to_wire() + salt).digest()
for _ in range(iterations):
digest = hashlib.sha1(digest + salt).digest()
return digest
def owner_hash(owner):
"""The first label of an NSEC3 owner name is the base32hex form of the hash it stands for."""
text = owner.labels[0].decode().upper()
return base64.b32hexdecode(text + "=" * (-len(text) % 8))
def covers(low, high, target):
"""Is target strictly between two NSEC3 hashes? The last record wraps around to the first."""
return low < target < high if low < high else target > low or target < high
def judge(child, types):
if rt.DS in types:
raise Bogus(f"the proof lists a DS record at {child}, yet none was delivered")
if rt.SOA in types:
raise Bogus(f"the proof for {child} comes from the child side of the zone cut")
if rt.NS in types:
return "insecure", f"{child} is delegated without a DS record, so it is an unsigned zone"
return "none", f"{child} is not a zone cut"
Then the function that ties them together:
# dnssec.py (continued)
def prove_no_ds(msg, child, zone, keys, now):
"""Check the signed proof that `child` has no DS record. Returns ('insecure', why) or ('none', why)."""
nsecs, nsec3s = collect(msg.authority, rt.NSEC), collect(msg.authority, rt.NSEC3)
if not nsecs and not nsec3s:
raise Bogus(f"no DS for {child} and no NSEC or NSEC3 proof of that (a stripped answer looks exactly like this)")
for rdtype, group in ((rt.NSEC, nsecs), (rt.NSEC3, nsec3s)):
for owner, (rdatas, sigs) in group.items():
verify_rrset(owner, rdtype, rdatas, sigs, zone, keys, now) # a proof nobody signed is worthless
if nsecs:
record = nsecs.get(child)
if record is None:
raise Bogus(f"none of the NSEC records belongs to {child}")
return judge(child, bitmap_types(record[0][0].windows))
params = next(iter(nsec3s.values()))[0][0]
if params.iterations > MAX_NSEC3_ITERATIONS:
raise Indeterminate(f"NSEC3 asks for {params.iterations} hash iterations; the limit here is {MAX_NSEC3_ITERATIONS}")
records = [(owner_hash(owner), rd) for owner, (rdatas, _) in nsec3s.items() for rd in rdatas]
def digest(name):
return nsec3_hash(name, params.salt, params.iterations)
for hashed, rd in records: # 1. a record for the name itself
if hashed == digest(child):
return judge(child, bitmap_types(rd.windows))
if not any(hashed == digest(child.parent()) for hashed, _ in records): # 2. otherwise: closest encloser plus opt-out
raise Bogus(f"no NSEC3 record matches {child} or its parent {child.parent()}")
for hashed, rd in records:
if covers(hashed, rd.next, digest(child)):
if rd.flags & 1:
return "insecure", f"{child} falls inside an NSEC3 opt-out span of {zone}: unsigned delegations are not listed"
raise Bogus(f"the NSEC3 record covering {child} does not have the opt-out flag")
raise Bogus(f"no NSEC3 record covers the hash of {child}")
The order inside prove_no_ds() matters. Every NSEC and NSEC3 record is signature-checked first, because a proof nobody signed is worthless. Only then does the code read the hash parameters, refuse an absurd iteration count, and look for a matching record, then for an opt-out cover. RFC 9276 says that “a validating resolver returning an insecure response MUST still validate the signature over the NSEC3 record to ensure the iteration count was not altered since record publication”, which is the same ordering rule seen from the other side. Run it:
# step06_unsigned.py
import base64
import time
import dns.name
import dns.rdatatype as rt
from anchors import ROOT_ANCHORS
from dnsq import ask
from dnssec import Validator, bitmap_types, collect, covers, nsec3_hash, owner_hash
def b32(digest):
return base64.b32hexencode(digest).decode().lower()
print("RFC 5155 Appendix A vector, 'example' with salt aabbccdd and 12 iterations:")
print(" ", b32(nsec3_hash(dns.name.from_text("example."), bytes.fromhex("aabbccdd"), 12)))
reply = ask("google.com", "DS")
records = collect(reply.authority, rt.NSEC3)
first = next(iter(records.values()))[0][0]
target = nsec3_hash(dns.name.from_text("google.com."), first.salt, first.iterations)
print()
print(f"What com. sent instead of a DS record: {len(records)} NSEC3 records, "
f"{first.iterations} extra hash iterations, salt {first.salt.hex() or 'none'}")
print(f"hash of google.com. = {b32(target)}")
for owner, (rdatas, _sigs) in records.items():
rd = rdatas[0]
types = " ".join(rt.to_text(t) for t in sorted(bitmap_types(rd.windows)))
role = "COVERS the hash" if covers(owner_hash(owner), rd.next, target) else "matches com. itself"
print(f" from {b32(owner_hash(owner))[:12]}... to {b32(rd.next)[:12]}... opt-out={rd.flags & 1} types: {types} <- {role}")
result = Validator(ask, ROOT_ANCHORS, time.time()).validate("google.com", "A")
print()
print(f"{result.status}: {result.reason}")
Run python step06_unsigned.py:
RFC 5155 Appendix A vector, 'example' with salt aabbccdd and 12 iterations:
0p9mhaveqvm6t7vbl5lop2u3t2rp3tom
What com. sent instead of a DS record: 2 NSEC3 records, 0 extra hash iterations, salt none
hash of google.com. = s84bqpstb1lvdtc4rt53intad6ujsco8
from ck0pojmg874l... to ck0q35hrs4h7... opt-out=1 types: NS SOA RRSIG DNSKEY NSEC3PARAM <- matches com. itself
from s84bor4dk28h... to s84br9cib2a2... opt-out=1 types: NS DS RRSIG <- COVERS the hash
INSECURE: google.com. falls inside an NSEC3 opt-out span of com.: unsigned delegations are not listed
The first line is a published test vector: RFC 5155 Appendix A says the hash of example with salt aabbccdd and 12 iterations is 0p9mhaveqvm6t7vbl5lop2u3t2rp3tom, and our function reproduces it. The com. zone uses no salt and no extra iterations, which is also what RFC 9276 recommends. The hash of google.com. falls between the owner hash and the next hash of the second record, which carries the opt-out flag, and the first record is the one that matches com. itself. The verdict is INSECURE, and it is the right one: Google’s zone was not signed when I ran this, and com. proved it with a signature.
Step 7: A command-line checker you can use in scripts
Wrap the validator in a small tool with exit codes, so shell scripts and monitoring systems can act on the verdict:
# dnssec_check.py
"""Usage: python dnssec_check.py NAME [TYPE] [--warn-days N]
Exit codes: 0 SECURE, 10 INSECURE, 20 BOGUS, 30 INDETERMINATE, 40 SECURE but a signature expires soon."""
import sys
import time
from anchors import ROOT_ANCHORS
from dnsq import ask
from dnssec import Validator, when
EXIT = {"SECURE": 0, "INSECURE": 10, "BOGUS": 20, "INDETERMINATE": 30}
def main(argv):
warn_days = float(argv[argv.index("--warn-days") + 1]) if "--warn-days" in argv else None
args = [a for i, a in enumerate(argv[1:], 1) if not a.startswith("--") and argv[i - 1] != "--warn-days"]
name, qtype = args[0], (args[1] if len(args) > 1 else "A")
now = time.time()
result = Validator(ask, ROOT_ANCHORS, now).validate(name, qtype)
print(f"{name} {qtype}: {result.status}")
print(f" {result.reason}")
for line in result.trail:
print(f" - {line}")
if result.expiries:
what, soonest, _alg = min(result.expiries, key=lambda e: e[1])
left = (soonest - now) / 86400
print(f" earliest expiry: {what}, {when(soonest)} UTC, in {left:.1f} days")
if warn_days is not None and result.status == "SECURE" and left < warn_days:
print(f" WARNING: less than {warn_days:g} days left")
return 40
return EXIT[result.status]
if __name__ == "__main__":
sys.exit(main(sys.argv))
The first run, on a signed domain, prints the whole trail:
python dnssec_check.py example.com
example.com A: SECURE
example.com. A is signed by example.com., and the chain of trust reaches the root
- . DNSKEY: 3 keys, set signed by key 38696, which the parent vouches for (signature expires 2026-11-21 00:00 UTC)
- com. DS: signed by . key 8763 (signature expires 2026-11-22 05:00 UTC)
- com. DNSKEY: 2 keys, set signed by key 19718, which the parent vouches for (signature expires 2026-11-18 15:02 UTC)
- example.com. DS: signed by com. key 65344 (signature expires 2026-11-13 01:34 UTC)
- example.com. DNSKEY: 4 keys, set signed by key 2371, which the parent vouches for (signature expires 2026-12-19 17:46 UTC)
- example.com. A: signed by example.com. key 34505 (signature expires 2026-11-10 21:37 UTC)
earliest expiry: example.com. A, 2026-11-10 21:37 UTC, in 1.0 days
exit code 0
An unsigned domain:
python dnssec_check.py google.com
google.com A: INSECURE
google.com. falls inside an NSEC3 opt-out span of com.: unsigned delegations are not listed
- . DNSKEY: 3 keys, set signed by key 38696, which the parent vouches for (signature expires 2026-11-21 00:00 UTC)
- com. DS: signed by . key 8763 (signature expires 2026-11-22 05:00 UTC)
- com. DNSKEY: 2 keys, set signed by key 19718, which the parent vouches for (signature expires 2026-11-18 15:02 UTC)
- google.com. DS: absent, and that is proven: google.com. falls inside an NSEC3 opt-out span of com.: unsigned delegations are not listed
earliest expiry: com. DNSKEY, 2026-11-18 15:02 UTC, in 8.8 days
exit code 10
And dnssec-failed.org, a domain whose DNSSEC records are known to fail validation:
python dnssec_check.py dnssec-failed.org
dnssec-failed.org A: BOGUS
no DNSKEY of dnssec-failed.org. matches any of its 1 DS records
- . DNSKEY: 3 keys, set signed by key 38696, which the parent vouches for (signature expires 2026-11-21 00:00 UTC)
- org. DS: signed by . key 8763 (signature expires 2026-11-22 05:00 UTC)
- org. DNSKEY: 5 keys, set signed by key 26974, which the parent vouches for (signature expires 2026-11-30 15:28 UTC)
- dnssec-failed.org. DS: signed by org. key 25488 (signature expires 2026-11-30 15:28 UTC)
earliest expiry: . DNSKEY, 2026-11-21 00:00 UTC, in 11.1 days
exit code 20
The verdict is BOGUS, and the reason is specific: the DS record at org. names key tag 42069, but the zone publishes keys 32784 and 50719, so the parent vouches for a key that does not exist. When I looked at the recorded replies there was a second fault, too: the signature of the key signing key over the DNSKEY set (tag 50719) had expired on 2026-07-02. Either one is enough to fail validation. The --warn-days option turns the tool into an expiry monitor:
python dnssec_check.py example.com --warn-days 7
example.com A: SECURE
example.com. A is signed by example.com., and the chain of trust reaches the root
- . DNSKEY: 3 keys, set signed by key 38696, which the parent vouches for (signature expires 2026-11-21 00:00 UTC)
- com. DS: signed by . key 8763 (signature expires 2026-11-22 05:00 UTC)
- com. DNSKEY: 2 keys, set signed by key 19718, which the parent vouches for (signature expires 2026-11-18 15:02 UTC)
- example.com. DS: signed by com. key 65344 (signature expires 2026-11-13 01:34 UTC)
- example.com. DNSKEY: 4 keys, set signed by key 2371, which the parent vouches for (signature expires 2026-12-19 17:46 UTC)
- example.com. A: signed by example.com. key 34505 (signature expires 2026-11-10 21:36 UTC)
earliest expiry: example.com. A, 2026-11-10 21:36 UTC, in 1.0 days
WARNING: less than 7 days left
exit code 40
It exits with 40 when the earliest signature in the chain expires within the window. That fires for example.com because the signature on its address record is only valid for about two days, as Step 4 showed, so pick a threshold per zone from the signature lifetimes you observe rather than one number for everything.
Step 8: Compare your verdicts with a validating resolver on 25 real domains
A good test of a validator is a second validator. The same public resolver that has been our messenger also validates for its own clients when you leave the CD flag off: it sets the AD flag for secure answers and returns SERVFAIL for bogus ones. This survey runs our validator and then asks the resolver what it concluded, for 25 domains I picked: big names, Internet infrastructure organizations, government sites, and the broken test domain.
# step07_survey.py
import time
import dns.flags
import dns.message
import dns.query
import dns.rcode
from anchors import ROOT_ANCHORS
from dnsq import RESOLVER, ask
from dnssec import Validator
DOMAINS = ["example.com", "iana.org", "ietf.org", "cloudflare.com", "icann.org", "isc.org", "internet.nl", "nlnetlabs.nl",
"verisign.com", "ripe.net", "nist.gov", "cisa.gov", "whitehouse.gov", "bund.de", "gov.uk", "debian.org",
"paypal.com", "google.com", "github.com", "amazon.com", "microsoft.com", "wikipedia.org", "python.org",
"sxz.io", "dnssec-failed.org"]
EXPECT = {"SECURE": "AD", "INSECURE": "no AD", "BOGUS": "SERVFAIL"}
def resolver_says(name):
"""Ask the same resolver again WITHOUT Checking Disabled, so it validates and reports what it found."""
query = dns.message.make_query(name, "A", want_dnssec=True, use_edns=0, payload=1232)
reply, _ = dns.query.udp_with_fallback(query, RESOLVER, timeout=5)
if reply.rcode() == dns.rcode.SERVFAIL:
return "SERVFAIL"
return "AD" if reply.flags & dns.flags.AD else "no AD"
print(f"{'domain':18} {'ours':9} {'resolver':9} {'agree':5} {'alg':>3} {'queries':>7} {'ms':>5} {'days left':>9}")
agree, counts = 0, {}
for name in DOMAINS:
start, now = time.perf_counter(), time.time()
result = Validator(ask, ROOT_ANCHORS, now).validate(name, "A")
elapsed = (time.perf_counter() - start) * 1000
theirs = resolver_says(name)
same = EXPECT.get(result.status) == theirs
agree += same
counts[result.status] = counts.get(result.status, 0) + 1
alg = str(result.expiries[-1][2]) if result.status == "SECURE" else "-"
days = f"{(min(e[1] for e in result.expiries) - now) / 86400:.1f}" if result.status == "SECURE" else "-"
print(f"{name:18} {result.status:9} {theirs:9} {'yes' if same else 'NO':5} {alg:>3} {result.queries:>7} {elapsed:5.0f} {days:>9}")
print()
print(f"agreement with the resolver's own verdict: {agree} of {len(DOMAINS)}; " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
Run python step07_survey.py:
domain ours resolver agree alg queries ms days left
example.com SECURE AD yes 13 6 304 1.0
iana.org SECURE AD yes 13 6 262 11.1
ietf.org SECURE AD yes 13 6 264 1.0
cloudflare.com SECURE AD yes 13 6 199 1.0
icann.org SECURE AD yes 13 6 232 11.1
isc.org SECURE AD yes 13 6 326 11.1
internet.nl SECURE AD yes 13 6 156 8.8
nlnetlabs.nl SECURE AD yes 8 6 153 11.1
verisign.com SECURE AD yes 8 6 139 3.2
ripe.net SECURE AD yes 13 6 206 4.3
nist.gov SECURE AD yes 8 6 147 1.0
cisa.gov SECURE AD yes 13 6 150 1.0
whitehouse.gov SECURE AD yes 13 6 190 1.0
bund.de SECURE AD yes 13 6 296 9.4
gov.uk SECURE AD yes 8 6 159 11.1
debian.org SECURE AD yes 8 6 198 11.1
paypal.com SECURE AD yes 13 6 153 4.2
google.com INSECURE no AD yes - 5 110 -
github.com INSECURE no AD yes - 5 119 -
amazon.com INSECURE no AD yes - 5 113 -
microsoft.com INSECURE no AD yes - 5 115 -
wikipedia.org INSECURE no AD yes - 5 175 -
python.org INSECURE no AD yes - 5 146 -
sxz.io INSECURE no AD yes - 5 115 -
dnssec-failed.org BOGUS SERVFAIL yes - 6 200 -
agreement with the resolver's own verdict: 25 of 25; BOGUS 1, INSECURE 7, SECURE 17
All 25 verdicts match: 17 signed domains where the resolver set the AD flag, 7 unsigned domains where it did not, and one broken domain where it answered SERVFAIL. A signed domain took 6 queries and an unsigned one 5, at roughly 110 to 330 milliseconds per domain on my connection. The alg column shows the algorithm of the signature that verified the answer: 12 domains use ECDSA P-256 (13) and 5 use RSA with SHA-256 (8). Among the unsigned names are google.com, github.com, amazon.com, microsoft.com, wikipedia.org, python.org, and this site’s own sxz.io. This is a hand-picked list from one day, not a sample of the Internet, so read it as a test of the validator, not as a statistic about DNSSEC adoption.
One more observation from the same day: 1.1.1.1, 8.8.8.8, and 9.9.9.9 all answered SERVFAIL for dnssec-failed.org when CD was off, and all returned the address when CD was on. That is how a broken signature takes a domain offline for everyone behind a validating resolver, and the CD flag is what lets a client such as ours look at the data anyway.
Step 9: Record the traffic, then attack it
To test the dangerous paths safely, we need replies we can edit. Add a recorder and a replayer to dnsq.py. The recorder wraps any fetch function and keeps every reply; the replayer serves them back from a file and raises KeyError for anything that was never recorded, so a test cannot silently reach the network:
# dnsq.py (continued)
def label(name, rdtype):
"""One text key per question, for example 'example.com./A'."""
owner = dns.name.from_text(str(name)).canonicalize().to_text()
return f"{owner}/{dns.rdatatype.to_text(dns.rdatatype.RdataType.make(rdtype))}"
class Recorder:
"""Wrap a fetch function and keep every reply, so a run can be saved and replayed offline."""
def __init__(self, fetch=ask):
self.fetch, self.replies = fetch, {}
def __call__(self, name, rdtype):
response = self.fetch(name, rdtype)
self.replies[label(name, rdtype)] = response.to_wire().hex()
return response
def save(self, path, now):
with open(path, "w", newline="\n") as handle:
json.dump({"now": now, "replies": self.replies}, handle)
class Replay:
"""Serve saved replies. A question that was not recorded is an error, so a test can never reach the network."""
def __init__(self, path):
with open(path) as handle:
data = json.load(handle)
self.now, self.replies = data["now"], data["replies"]
def __call__(self, name, rdtype):
return dns.message.from_wire(bytes.fromhex(self.replies[label(name, rdtype)]))
The capture script validates three domains for real and saves every reply it sees to snapshot.json, along with the time it ran. Tests will use that time as their clock, so the saved signatures are always checked against the moment they were recorded:
# capture.py
"""Validate three domains against the live Internet and save every reply for offline tests."""
import time
from anchors import ROOT_ANCHORS
from dnsq import Recorder
from dnssec import Validator
recorder, now = Recorder(), int(time.time())
for name in ["example.com", "google.com", "dnssec-failed.org"]:
result = Validator(recorder, ROOT_ANCHORS, now).validate(name, "A")
print(f"{name:20} {result.status:8} {result.reason[:110]}")
recorder.save("snapshot.json", now)
print(len(recorder.replies), "replies saved to snapshot.json")
Run python capture.py:
example.com SECURE example.com. A is signed by example.com., and the chain of trust reaches the root
google.com INSECURE google.com. falls inside an NSEC3 opt-out span of com.: unsigned delegations are not listed
dnssec-failed.org BOGUS no DNSKEY of dnssec-failed.org. matches any of its 1 DS records
13 replies saved to snapshot.json
Now the attacker’s toolbox. Each function edits one recorded reply the way a machine on the network path might: flipping a bit, deleting records, rewriting a signer name, or replacing a zone’s keys with the attacker’s own. The first block holds the small edits:
# attacks.py
"""Tamper with recorded replies, the way an attacker in the network path could."""
import base64
import dns.name
import dns.rdata
import dns.rdataclass
import dns.rdatatype as rt
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
from dns.rdtypes.ANY.RRSIG import RRSIG
from dnsq import label
from dnssec import key_tag, owner_labels, signed_data
IN = dns.rdataclass.IN
def tamper(replay, name, rdtype, edit):
"""A fetch function that serves `replay`, except that the reply to one question goes through edit(msg) first."""
wanted = label(name, rdtype)
def fetch(n, t):
msg = replay(n, t)
if label(n, t) == wanted:
edit(msg)
return msg
return fetch
def put(rrset, rdatas):
rrset.clear()
for rd in rdatas:
rrset.add(rd)
def flip_address_bit(msg):
for rrset in msg.answer:
if rrset.rdtype == rt.A:
raw = bytearray(rrset[0].to_wire())
raw[-1] ^= 1
put(rrset, [dns.rdata.from_wire(IN, rt.A, bytes(raw), 0, 4)])
def drop_signatures(msg):
msg.answer = [r for r in msg.answer if r.rdtype != rt.RRSIG]
def strip_ds(msg):
msg.answer = [r for r in msg.answer if r.rdtype != rt.DS and not (r.rdtype == rt.RRSIG and r.covers == rt.DS)]
def graft_proof(donor):
"""Answer a DS question with the (genuine, signed) NSEC3 proof that was issued for another name."""
def edit(msg):
msg.answer, msg.authority = [], list(donor.authority)
return edit
def set_signer(covered, signer):
"""Rewrite the signer name of the RRSIG that covers one record type."""
def edit(msg):
for rrset in msg.answer:
if rrset.rdtype == rt.RRSIG and rrset.covers == covered:
parts = rrset[0].to_text().split(" ")
parts[7] = signer
put(rrset, [dns.rdata.from_text(IN, rt.RRSIG, " ".join(parts))])
return edit
def drop_authority_signatures(msg):
msg.authority = [r for r in msg.authority if r.rdtype != rt.RRSIG]
The second block is the sophisticated one. forged_zone() generates a fresh ECDSA key and replaces a zone’s DNSKEY set and its address record with ones signed consistently by that key. Every signature in the forged data is mathematically valid. It must fail anyway, because no parent ever vouched for that key:
# attacks.py (continued)
def attacker_key():
private = ec.generate_private_key(ec.SECP256R1())
point = private.public_key().public_numbers()
raw = point.x.to_bytes(32, "big") + point.y.to_bytes(32, "big")
return private, dns.rdata.from_text(IN, rt.DNSKEY, "257 3 13 " + base64.b64encode(raw).decode())
def sign(name, rdtype, rdatas, zone, private, dnskey, now):
"""Make a valid ECDSA P-256 RRSIG with the attacker's key."""
draft = RRSIG(IN, rt.RRSIG, rdtype, 13, owner_labels(name), 300, now + 7 * 86400, now - 3600, key_tag(dnskey), zone, b"")
der = private.sign(signed_data(draft, name, rdtype, rdatas), ec.ECDSA(hashes.SHA256()))
r, s = utils.decode_dss_signature(der)
return RRSIG(IN, rt.RRSIG, rdtype, 13, draft.labels, 300, draft.expiration, draft.inception, draft.key_tag, zone,
r.to_bytes(32, "big") + s.to_bytes(32, "big"))
def forged_zone(replay, zone, now, address="192.0.2.66"):
"""Replace a zone's DNSKEY set and its A record with the attacker's own, signed consistently with the attacker's key."""
private, dnskey = attacker_key()
fake_a = dns.rdata.from_text(IN, rt.A, address)
owner = dns.name.from_text(zone)
def edit_keys(msg):
for rrset in msg.answer:
if rrset.rdtype == rt.DNSKEY:
put(rrset, [dnskey])
elif rrset.rdtype == rt.RRSIG and rrset.covers == rt.DNSKEY:
put(rrset, [sign(owner, rt.DNSKEY, [dnskey], owner, private, dnskey, now)])
def edit_a(msg):
for rrset in msg.answer:
if rrset.rdtype == rt.A:
put(rrset, [fake_a])
elif rrset.rdtype == rt.RRSIG and rrset.covers == rt.A:
put(rrset, [sign(owner, rt.A, [fake_a], owner, private, dnskey, now)])
return tamper(tamper(replay, zone, "DNSKEY", edit_keys), zone, "A", edit_a)
def inject_key(msg):
"""Add the attacker's key to the genuine DNSKEY set without being able to re-sign it."""
_, dnskey = attacker_key()
for rrset in msg.answer:
if rrset.rdtype == rt.DNSKEY:
rrset.add(dnskey)
The third block lists every attack with the verdict and the reason we expect, so the table can be printed and also reused by the tests:
# attacks.py (continued)
# ---- the attack matrix, shared by step08_attacks.py and the tests -------------------------
from collections import namedtuple
from anchors import ROOT_ANCHORS
from dnssec import BOGUS, INSECURE, SECURE, get
Case = namedtuple("Case", "title fetch now status words name anchors")
def cases(replay):
now, example = replay.now, dns.name.from_text("example.com")
a_sig = get(replay("example.com", "A").answer, example, rt.A)[1][0]
inceptions = [sig.inception for text in replay.replies for rrset in replay(*text.split("/")).answer
if rrset.rdtype == rt.RRSIG and text.startswith(("./", "com.", "example.com.")) for sig in rrset]
bad_digest = ROOT_ANCHORS[1].to_text()[:-1] + "0"
bad_anchors = [dns.rdata.from_text(IN, rt.DS, bad_digest)]
def case(title, fetch, status, words, clock=now, name="example.com", anchors=ROOT_ANCHORS):
return Case(title, fetch, clock, status, words, name, anchors)
return [
case("genuine replay", replay, SECURE, "chain of trust"),
case("one bit of the address flipped", tamper(replay, "example.com", "A", flip_address_bit), BOGUS, "does not verify"),
case("every RRSIG removed from the answer", tamper(replay, "example.com", "A", drop_signatures), BOGUS, "is unsigned"),
case("clock one second after the signature expires", replay, BOGUS, "validity window", clock=a_sig.expiration + 1),
case("clock a day before the earliest inception", replay, BOGUS, "validity window", clock=min(inceptions) - 86400),
case("DS record stripped from the parent's reply", tamper(replay, "example.com", "DS", strip_ds), BOGUS, "no NSEC or NSEC3 proof"),
case("DS reply swapped for google.com's NSEC3 proof",
tamper(replay, "example.com", "DS", graft_proof(replay("google.com", "DS"))), BOGUS, "NSEC3"),
case("forged zone with its own self-consistent key", forged_zone(replay, "example.com", now), BOGUS, "matches any of its"),
case("attacker key added to the real DNSKEY set", tamper(replay, "example.com", "DNSKEY", inject_key), BOGUS, "does not verify"),
case("answer says the parent zone signed it", tamper(replay, "example.com", "A", set_signer(rt.A, "com.")), BOGUS, "no key"),
case("DS reply whose RRSIG names the child as signer",
tamper(replay, "example.com", "DS", set_signer(rt.DS, "example.com.")), BOGUS, "signer example.com. is not com."),
case("NSEC3 proof with its signatures removed", tamper(replay, "google.com", "DS", drop_authority_signatures),
BOGUS, "no RRSIG", name="google.com"),
case("trust anchor with one wrong digit", replay, BOGUS, "matches any of its", anchors=bad_anchors),
case("dnssec-failed.org as recorded", replay, BOGUS, "matches any of its", name="dnssec-failed.org"),
case("google.com as recorded (unsigned by design)", replay, INSECURE, "opt-out", name="google.com"),
]
# step08_attacks.py
from attacks import cases
from dnsq import Replay
from dnssec import Validator
replay = Replay("snapshot.json")
print(f"{'attack':50} {'verdict':9} reason")
for c in cases(replay):
result = Validator(c.fetch, c.anchors, c.now).validate(c.name, "A")
print(f"{c.title:50} {result.status:9} {result.reason[:88]}")
Run python step08_attacks.py:
attack verdict reason
genuine replay SECURE example.com. A is signed by example.com., and the chain of trust reaches the root
one bit of the address flipped BOGUS example.com. A: signature made with key 34505 does not verify
every RRSIG removed from the answer BOGUS example.com. A is unsigned, example.com. is a signed zone, and no unsigned delegation wa
clock one second after the signature expires BOGUS example.com. A: signature outside its validity window (2026-11-08 19:37 to 2026-11-10 21
clock a day before the earliest inception BOGUS . DNSKEY: signature outside its validity window (2026-10-31 00:00 to 2026-11-21 00:00 UT
DS record stripped from the parent's reply BOGUS no DS for example.com. and no NSEC or NSEC3 proof of that (a stripped answer looks exact
DS reply swapped for google.com's NSEC3 proof BOGUS no NSEC3 record covers the hash of example.com.
forged zone with its own self-consistent key BOGUS no DNSKEY of example.com. matches any of its 1 DS records
attacker key added to the real DNSKEY set BOGUS example.com. DNSKEY: signature made with key 2371 does not verify
answer says the parent zone signed it BOGUS example.com. A: no key 34505 (algorithm 13) in the DNSKEY set of com.
DS reply whose RRSIG names the child as signer BOGUS example.com. DS: signer example.com. is not com.
NSEC3 proof with its signatures removed BOGUS ck0pojmg874ljref7efn8430qvit8bsm.com. NSEC3: no RRSIG
trust anchor with one wrong digit BOGUS no DNSKEY of . matches any of its 1 DS records
dnssec-failed.org as recorded BOGUS no DNSKEY of dnssec-failed.org. matches any of its 1 DS records
google.com as recorded (unsigned by design) INSECURE google.com. falls inside an NSEC3 opt-out span of com.: unsigned delegations are not lis
Read the table by groups. Data tampering (rows 2 and 3): a flipped bit fails the signature, and removing all signatures fails because a signed zone may not return unsigned data. Time (rows 4 and 5): a clock one second past expiry, or a day before the earliest inception, is rejected. Downgrade (rows 6, 7, and 12): deleting the DS record, replacing it with another name’s genuine NSEC3 proof, or stripping the signatures from a proof all end in BOGUS, because the proof has to match, be signed, and cover the right name. Key substitution (rows 8, 9, 13, and 14): a forged zone is rejected because no DNSKEY matches the DS, an injected key breaks the DNSKEY signature, a trust anchor with one wrong digit fails at the root, and the real dnssec-failed.org fails for the reason you saw in Step 7. Signer confusion (rows 10 and 11): an answer that claims the parent signed it, or a DS record that claims the child signed it, is rejected by the signer rule. The last row shows what a legitimate unsigned zone looks like: INSECURE, with the proof named.
Step 10: Tests, a second opinion, and planted bugs
The attack table is also the core of the test suite. Create test_dnssec.py. It checks the small pieces against published vectors and against dnspython, runs all 15 rows of the attack matrix, and includes a handful of targeted tests, such as the RFC 4034 example key (tag 60485, SHA-1 digest 2BB183AF…), an RFC 5155 hash vector, a signature that covers a record set in reverse order, and an opt-out record that lacks its flag:
# test_dnssec.py
import os
from types import SimpleNamespace
import dns.dnssec
import dnssec
import dns.message
import dns.name
import dns.rdata
import dns.rdataclass
import dns.rdatatype as rt
import pytest
from attacks import cases
from anchors import ROOT_ANCHORS
from dnsq import Replay
from dnssec import (BOGUS, INDETERMINATE, INSECURE, ROOT, SECURE, Indeterminate, Validator, bitmap_types, covers,
ds_digest, get, in_window, key_tag, matches_ds, nsec3_hash, public_key, serial_ge, verify_rrset)
HERE = os.path.dirname(os.path.abspath(__file__))
EXAMPLE = dns.name.from_text("example.com")
@pytest.fixture(scope="module")
def replay():
return Replay(os.path.join(HERE, "snapshot.json"))
def every_message(replay):
return [dns.message.from_wire(bytes.fromhex(wire)) for wire in replay.replies.values()]
# ---- the small pieces ----
def test_key_tags_agree_with_dnspython(replay):
seen = 0
for msg in every_message(replay):
for rrset in msg.answer:
if rrset.rdtype == rt.DNSKEY:
for key in rrset:
assert key_tag(key) == dns.dnssec.key_id(key)
seen += 1
assert seen >= 8
def test_a_recorded_root_key_matches_a_trust_anchor(replay):
keys = get(replay(".", "DNSKEY").answer, ROOT, rt.DNSKEY)[0]
assert any(matches_ds(ROOT, key, anchor) for key in keys for anchor in ROOT_ANCHORS)
def test_ds_digest_ignores_case_of_the_owner_name(replay):
key = get(replay("com.", "DNSKEY").answer, dns.name.from_text("com."), rt.DNSKEY)[0][0]
assert ds_digest(dns.name.from_text("COM."), key, 2) == ds_digest(dns.name.from_text("com."), key, 2)
def test_serial_arithmetic_survives_the_wrap():
assert serial_ge(5, 3) and not serial_ge(3, 5)
assert serial_ge(1, 0xFFFFFFF0) and not serial_ge(0xFFFFFFF0, 1)
def test_window_edges_and_wrap():
sig = SimpleNamespace(inception=1000, expiration=2000)
assert [in_window(sig, t) for t in (999, 1000, 2000, 2001)] == [False, True, True, False]
wrap = SimpleNamespace(inception=0xFFFFFF00, expiration=0x100)
assert [in_window(wrap, t) for t in (0xFFFFFE00, 0xFFFFFF80, 0x80, 0x200)] == [False, True, True, False]
def test_bitmap_types_agree_with_dnspython(replay):
checked = 0
for msg in every_message(replay):
for rrset in msg.authority:
if rrset.rdtype == rt.NSEC3:
for rd in rrset:
expected = {int(rt.from_text(word)) for word in rd.to_text().split()[5:]}
assert bitmap_types(rd.windows) == expected
checked += 1
assert checked >= 2
def test_nsec3_hash_matches_the_rfc_5155_vector():
digest = nsec3_hash(dns.name.from_text("example."), bytes.fromhex("aabbccdd"), 12)
assert dns.dnssec.nsec3_hash("example.", "aabbccdd", 12, 1) == "0P9MHAVEQVM6T7VBL5LOP2U3T2RP3TOM"
import base64
assert base64.b32hexencode(digest).decode() == "0P9MHAVEQVM6T7VBL5LOP2U3T2RP3TOM"
def test_covers_handles_the_wrap_around():
assert covers(b"\x10", b"\x20", b"\x15") and not covers(b"\x10", b"\x20", b"\x25")
assert covers(b"\xf0", b"\x10", b"\xf8") and covers(b"\xf0", b"\x10", b"\x05")
assert not covers(b"\xf0", b"\x10", b"\x80")
def test_record_order_in_the_reply_does_not_matter(replay):
keys, sigs = get(replay(".", "DNSKEY").answer, ROOT, rt.DNSKEY)
assert verify_rrset(ROOT, rt.DNSKEY, list(reversed(keys)), sigs, ROOT, keys, replay.now)
def test_an_unsupported_algorithm_is_indeterminate():
odd = dns.rdata.from_text(dns.rdataclass.IN, rt.DNSKEY, "257 3 16 AAAA")
with pytest.raises(Indeterminate):
public_key(odd)
def test_a_wildcard_expansion_is_indeterminate(replay):
rdatas, sigs = get(replay("example.com", "A").answer, EXAMPLE, rt.A)
parts = sigs[0].to_text().split(" ")
parts[2] = "1"
narrow = dns.rdata.from_text(dns.rdataclass.IN, rt.RRSIG, " ".join(parts))
keys = get(replay("example.com", "DNSKEY").answer, EXAMPLE, rt.DNSKEY)[0]
with pytest.raises(Indeterminate):
verify_rrset(EXAMPLE, rt.A, rdatas, [narrow], EXAMPLE, keys, replay.now)
def test_the_replay_cannot_reach_the_network(replay):
with pytest.raises(KeyError):
replay("never-recorded.example", "A")
# ---- whole validations ----
def test_the_outcomes_are_the_documented_ones(replay):
results = {}
for name in ("example.com", "google.com", "dnssec-failed.org"):
results[name] = Validator(replay, ROOT_ANCHORS, replay.now).validate(name, "A").status
assert results == {"example.com": SECURE, "google.com": INSECURE, "dnssec-failed.org": BOGUS}
def test_the_trail_names_every_hop(replay):
result = Validator(replay, ROOT_ANCHORS, replay.now).validate("example.com", "A")
steps = [line.split(":")[0] for line in result.trail]
assert steps == [". DNSKEY", "com. DS", "com. DNSKEY", "example.com. DS", "example.com. DNSKEY", "example.com. A"]
assert result.queries == 6
@pytest.mark.parametrize("index", range(15))
def test_attack_matrix(replay, index):
case = cases(replay)[index]
result = Validator(case.fetch, case.anchors, case.now).validate(case.name, "A")
assert result.status == case.status, case.title + ": " + result.reason
assert case.words in result.reason, case.title + ": " + result.reason
def test_a_covering_nsec3_record_without_opt_out_proves_nothing_about_delegations(replay, monkeypatch):
msg = replay("google.com", "DS")
for rrset in msg.authority:
if rrset.rdtype == rt.NSEC3 and rrset[0].flags & 1 and rrset.name.labels[0].lower().startswith(b"s84b"):
rd = rrset[0]
rrset.clear()
rrset.add(dns.rdata.from_text(dns.rdataclass.IN, rt.NSEC3, "1 0 0 - " + rd.to_text().split()[4] + " NS DS RRSIG"))
monkeypatch.setattr(dnssec, "verify_rrset", lambda *args, **kwargs: None) # skip signatures: we are testing the logic
with pytest.raises(dnssec.Bogus, match="opt-out"):
dnssec.prove_no_ds(msg, dns.name.from_text("google.com."), dns.name.from_text("com."), [], replay.now)
def test_the_rfc_4034_example_key_has_the_published_key_tag_and_sha1_digest():
owner = dns.name.from_text("dskey.example.com.")
key = dns.rdata.from_text(dns.rdataclass.IN, rt.DNSKEY,
"256 3 5 AQOeiiR0GOMYkDshWoSKz9XzfwJr1AYtsmx3TGkJaNXVbfi/2pHm822aJ5iI9BMzNXxeYCmZ"
"DRD99WYwYqUSdjMmmAphXdvxegXd/M5+X7OrzKBaMbCVdFLUUh6DhweJBjEVv5f2wwjM9XzcnOf+EPbt"
"G9DMBmADjFDc2w/rljwvFw==")
assert key_tag(key) == 60485
assert ds_digest(owner, key, 1).hex().upper() == "2BB183AF5F22588179A53B0A98631FAD1A292118"
Run python -m pytest -q test_dnssec.py:
............................... [100%]
31 passed in 0.17s
A second opinion from dnspython
Passing your own tests proves little if the tests share your misunderstandings. dnspython ships its own validator, dns.dnssec.validate(), so let it judge every signed RRset in the snapshot, once as received and once with one bit flipped, and compare its verdict with ours:
# step09_crosscheck.py
import dns.dnssec
import dns.message
import dns.rdata
import dns.rdataset
import dns.rdatatype as rt
import dns.rrset
from dnsq import Replay
from dnssec import Bogus, get, verify_rrset
replay = Replay("snapshot.json")
def flipped(rrset):
"""The same RRset with the last bit of its first record changed."""
raw = bytearray(rrset[0].to_wire())
raw[-1] ^= 1
first = dns.rdata.from_wire(rrset.rdclass, rrset.rdtype, bytes(raw), 0, len(raw))
return dns.rrset.from_rdata_list(rrset.name, rrset.ttl, [first] + list(rrset)[1:])
def dnspython_accepts(rrset, sigs, zone, keys):
rrsigs = dns.rrset.from_rdata_list(rrset.name, rrset.ttl, sigs)
try:
dns.dnssec.validate(rrset, rrsigs, {zone: dns.rdataset.from_rdata_list(3600, keys)}, now=replay.now)
return True
except dns.dnssec.ValidationFailure:
return False
def ours_accepts(rrset, sigs, zone, keys):
try:
verify_rrset(rrset.name, rrset.rdtype, list(rrset), sigs, zone, keys, replay.now)
return True
except Bogus:
return False
compared = disagreements = accepted = rejected = 0
for text, wire in replay.replies.items():
msg = dns.message.from_wire(bytes.fromhex(wire))
for section in (msg.answer, msg.authority):
for rrset in section:
sigs = get(section, rrset.name, rrset.rdtype)[1] if rrset.rdtype != rt.RRSIG else []
if not sigs or f"{sigs[0].signer}/DNSKEY" not in replay.replies:
continue
zone = sigs[0].signer
keys = get(replay(zone, "DNSKEY").answer, zone, rt.DNSKEY)[0]
for candidate in (rrset, flipped(rrset)):
mine, theirs = ours_accepts(candidate, sigs, zone, keys), dnspython_accepts(candidate, sigs, zone, keys)
compared += 1
accepted += mine
rejected += not mine
if mine != theirs:
disagreements += 1
print("DISAGREE", text, rt.to_text(rrset.rdtype), "ours", mine, "dnspython", theirs)
print(f"compared {compared} verdicts (each signed RRset once as received and once with a bit flipped)")
print(f"accepted {accepted}, rejected {rejected}, disagreements with dnspython: {disagreements}")
Run python step09_crosscheck.py:
compared 28 verdicts (each signed RRset once as received and once with a bit flipped)
accepted 14, rejected 14, disagreements with dnspython: 0
Of 28 verdicts, both validators accepted 14 and rejected 14, with zero disagreements. The snapshot holds DNSKEY, DS, NSEC3, and address records from several zones and two algorithms.
Do the tests notice a bug? Plant twelve
The last check is the most humbling. mutations.py copies the lab to a temporary folder, breaks one line of dnssec.py at a time, and reruns the suite. A bug that no test notices is a hole in the tests:
# mutations.py
"""Plant one bug at a time in a copy of dnssec.py and check that the tests notice."""
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
MUTANTS = [
("skip the signature check", "if check_signature(key, sig, data):", "if True:"),
("ignore the validity window", "elif not in_window(sig, now):", "elif False:"),
("accept any signer name", "if sig.signer != zone:", "if False:"),
("take a missing proof as 'unsigned zone'",
' raise Bogus(f"no DS for {child} and no NSEC or NSEC3 proof of that (a stripped answer looks exactly like this)")',
' return "insecure", "no proof needed"'),
("do not verify the NSEC3 signatures", "verify_rrset(owner, rdtype, rdatas, sigs, zone, keys, now) # a proof nobody signed is worthless", "pass"),
("ignore the opt-out flag", "if rd.flags & 1:", "if True:"),
("accept a DS digest that does not match",
"ds.key_tag == key_tag(dnskey) and ds_digest(owner, dnskey, ds.digest_type) == ds.digest)", "ds.key_tag == key_tag(dnskey))"),
("hash one NSEC3 iteration too many", "for _ in range(iterations):", "for _ in range(iterations + 1):"),
("read the type bitmap bits backwards", "if byte & (0x80 >> bit):", "if byte & (1 << bit):"),
("trust the DNSKEY set without checking its signature",
"sig = verify_rrset(zone, rt.DNSKEY, keys, sigs, zone, entry, self.now)", "sig = sigs[0]"),
("use the records in the order received",
"for wire in sorted({rd.to_digestable() for rd in rdatas}):", "for wire in [rd.to_digestable() for rd in rdatas]:"),
("forget that the NSEC3 chain wraps around", "else target > low or target < high", "else False"),
]
def run_tests(folder):
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", COLUMNS="170")
done = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", "test_dnssec.py"],
cwd=folder, capture_output=True, text=True, env=env)
failed = [line for line in done.stdout.splitlines() if line.startswith("FAILED")]
return done.returncode, (failed[0][:100] if failed else "")
caught = 0
for title, old, new in MUTANTS:
with tempfile.TemporaryDirectory() as tmp:
for name in os.listdir(HERE):
if name.endswith(".py") or name == "snapshot.json":
shutil.copy(os.path.join(HERE, name), tmp)
source = open(os.path.join(tmp, "dnssec.py"), encoding="utf-8").read()
assert source.count(old) == 1, f"mutant {title!r}: expected one match, found {source.count(old)}"
open(os.path.join(tmp, "dnssec.py"), "w", encoding="utf-8", newline="\n").write(source.replace(old, new))
code, first = run_tests(tmp)
caught += code != 0
print(f"{'caught ' if code != 0 else 'SURVIVED'} {title:50} {first}")
print(f"{caught} of {len(MUTANTS)} planted bugs were caught")
Run python mutations.py:
caught skip the signature check FAILED test_dnssec.py::test_attack_matrix[1] - AssertionError: one bit of the address flipped: examp
caught ignore the validity window FAILED test_dnssec.py::test_attack_matrix[3] - AssertionError: clock one second after the signature
caught accept any signer name FAILED test_dnssec.py::test_attack_matrix[10] - AssertionError: DS reply whose RRSIG names the child
caught take a missing proof as 'unsigned zone' FAILED test_dnssec.py::test_attack_matrix[5] - AssertionError: DS record stripped from the parent's
caught do not verify the NSEC3 signatures FAILED test_dnssec.py::test_attack_matrix[11] - AssertionError: NSEC3 proof with its signatures remo
caught ignore the opt-out flag FAILED test_dnssec.py::test_a_covering_nsec3_record_without_opt_out_proves_nothing_about_delegations
caught accept a DS digest that does not match FAILED test_dnssec.py::test_attack_matrix[12] - AssertionError: trust anchor with one wrong digit: e
caught hash one NSEC3 iteration too many FAILED test_dnssec.py::test_nsec3_hash_matches_the_rfc_5155_vector - AssertionError: assert 'CUBI0LT
caught read the type bitmap bits backwards FAILED test_dnssec.py::test_bitmap_types_agree_with_dnspython - AssertionError: assert {1, 5, 41, 52
caught trust the DNSKEY set without checking its signature FAILED test_dnssec.py::test_attack_matrix[8] - AssertionError: attacker key added to the real DNSKEY
caught use the records in the order received FAILED test_dnssec.py::test_record_order_in_the_reply_does_not_matter - dnssec.Bogus: . DNSKEY: sign
caught forget that the NSEC3 chain wraps around FAILED test_dnssec.py::test_covers_handles_the_wrap_around - AssertionError: assert (False)
12 of 12 planted bugs were caught
All twelve are caught. It was not that way at first. My original attack table let three of the twelve bugs survive: accepting any signer name, skipping the signature check on NSEC3 records, and ignoring the opt-out flag. Each one matches a real mistake a validator could make. The first two hid behind checks that failed for another reason: the row where the answer claims the parent signed it failed on the key lookup, so the signer rule was never exercised. The third was simply never exercised. Two new attack rows (the DS signed by the child, the proof with its signatures removed) and one unit test fixed that. The lesson is the one the whole article rests on: a test proves only what it exercises.
Common mistakes and gotchas
Trusting the AD flag
The authenticated-data flag is a claim by whoever answered your query. It is useful when you trust that resolver and the path to it, such as a resolver on the same host. Across a network it can be forged as easily as the data it vouches for. Validate yourself, or reach the resolver over a channel you trust.
Treating a missing DS record as proof of an unsigned zone
This is the downgrade attack from Step 6, and it is an easy mistake to make in a homemade validator. A missing DS is only acceptable alongside a signed proof that it is missing. Three rows of the attack table cover it, and so do two of the planted bugs.
Using the wrong TTL, case, or order
All three produce signatures that fail for genuine data. The original TTL comes from the RRSIG, owner names are lowercased, and records are sorted by their canonical data. Step 3 shows each.
Believing a key tag
Tags are 16-bit checksums and collide. Match on algorithm and tag, then try every candidate key. Never assume one hit is the only hit.
A wrong clock
A validator whose clock is off by a day rejects zones with short signature lifetimes. Run NTP, and when validation fails everywhere, check the time before you check the code.
Doing unbounded work for NSEC3
Each NSEC3 hash costs one SHA-1 computation per iteration, and a malicious zone can ask for a large number. RFC 9276 notes that “Validating resolvers MAY return an insecure response to their clients when processing NSEC3 records with iterations larger than 0.” Our code refuses more than 100, after checking the signature, and answers INDETERMINATE.
What this validator does not do
Read this list before you ever depend on the code, because each item is a case where it declines to answer or answers more cautiously than a production resolver:
- It does not prove that a name or record type does not exist. An
NXDOMAINor an empty answer returnsINDETERMINATE. Proving non-existence takes the closest-encloser proof, which also needs a covering record for the wildcard. - It does not validate wildcard answers. A signature whose label count is lower than the owner’s returns
INDETERMINATE. - Some algorithms are missing. It implements 8, 10, 13, 14, and 15. RFC 8624 also says validators MUST support RSASHA1 (5) and RSASHA1-NSEC3-SHA1 (7) and recommends ED448 (16), so a zone using only those comes back
INDETERMINATEhere. - It answers
INDETERMINATEwhere the RFC says to treat the zone as unsigned. RFC 4035 says that when a validator supports none of the algorithms in an authenticated DS RRset, “The resolver should treat this case as it would the case of an authenticated NSEC RRset proving that no DS RRset exists”. ReportingINDETERMINATEis a deliberate, louder choice. - It does not follow trust-anchor updates. The root key is being replaced, and a long-running validator needs the automated update mechanism for trust anchors (RFC 5011), not a constant in a file.
- It has no cache. A real resolver remembers verified keys until their TTL runs out. This one starts from scratch each time.
For production, use a validating resolver such as Unbound, BIND, Knot Resolver, or PowerDNS Recursor. What you built here is for understanding, testing, and monitoring.
Confirm the whole thing works end to end
From a clean folder, with the virtual environment active and all the files in place, these commands should all succeed:
python capture.py
python step08_attacks.py
python -m pytest -q test_dnssec.py
python step09_crosscheck.py
python mutations.py
python dnssec_check.py example.com
python dnssec_check.py google.com
python dnssec_check.py dnssec-failed.org
python step07_survey.py
You should see example.com come back SECURE, google.com INSECURE, and dnssec-failed.org BOGUS. The survey should agree with the resolver on every row, the attack table should have 15 rows of which only the first (SECURE) and the last (INSECURE) are not BOGUS, pytest should report 31 passed, and the mutation run should end with 12 of 12 planted bugs were caught. Dates and some timings will differ from mine, because they depend on the day you run it.
Where to go next
- Prove non-existence. Add the NXDOMAIN and no-data proofs with the closest-encloser logic, so that an empty answer can be
SECUREtoo. - Follow the trust anchor. Read the root key rollover article and add RFC 5011 tracking so the anchors update themselves.
- Run it from cron. Use
--warn-daysagainst the zones you operate, with a threshold per zone, and page someone on exit code 40 before a signature expires. - Build the resolver underneath. The DNS resolver tutorial walks from the root servers to an answer, and combining it with this validator gives you a small validating resolver.
- Compare with another trust system. The private certificate authority tutorial builds the web’s version of a chain of trust, with its own anchors and its own ways to fail.
Sources
- RFC 4033, DNS Security Introduction and Requirements.
- RFC 4034, Resource Records for the DNS Security Extensions (key tags, DS digests, signed data, serial arithmetic).
- RFC 4035, Protocol Modifications for the DNS Security Extensions (validation rules, CD and AD bits).
- RFC 5155, DNS Security (DNSSEC) Hashed Authenticated Denial of Existence.
- RFC 8624, Algorithm Implementation Requirements and Usage Guidance for DNSSEC.
- RFC 9276, Guidance for NSEC3 Parameter Settings.
- IANA root-anchors.xml, the published root trust anchors.
- dnspython documentation and cryptography documentation.








No Comment! Be the first one.