DNSSEC adds origin authentication and integrity checking for DNS data. RRSIG records sign RRsets, DNSKEY records publish verification keys, and a parent’s DS record identifies a child DNSKEY through a digest. The parent signs its DS RRset; it does not simply sign the child’s public key directly. Validation follows these relationships from a configured trust anchor to the requested data.
When it breaks
Publishing signatures is insufficient when the delegation does not establish the intended trust path. A missing DS can leave a child insecure, while an incorrect DS can make a supposedly secure child fail validation. Those are different states. Securely established absence of DS does not require every unsigned or independently signed child answer to be rejected as bogus.
Signatures also have inception and expiration times independent of an answer’s ordinary cache TTL. A signed zone can keep serving records after its signatures expire, but validators may reject them. Incorrect clocks, incomplete key rollovers, and stale parent DS data can therefore disrupt a zone that still answers ordinary queries. Monitoring only the presence of DNSKEY or RRSIG misses the actual validation boundary.
The authenticated-data AD flag must be interpreted through a trusted resolver path. Seeing the bit in an arbitrary unprotected response does not make that response trustworthy; an attacker can alter an unauthenticated message. Likewise, asking for DNSSEC records with DO is not validation. Establish which component validates and how the application trusts its response before treating a displayed flag as a security guarantee.
Check it quickly
Save this as check-dnssec.py and run python3 check-dnssec.py with Docker. The pinned BIND 9.18.49 environment creates disposable keys and offline-signed parent and child zones. A separate validating resolver trusts a local lab.test anchor and forwards only that test namespace to the authority. Private keys stay inside the container, and the host’s trust configuration and clock are unchanged:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype
def run(*args,check=True):
result=subprocess.run(args,capture_output=True,text=True,timeout=15)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def query(name,kind='A',port=1053):
return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
wanted=dns.rdatatype.from_text(kind)
return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
try:
if predicate():return
except (OSError,dns.exception.Timeout):pass
time.sleep(.05)
raise AssertionError('DNS fixture condition did not become true')
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
def save(body):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
run('named-checkzone','lab.test',str(zone))
save(records)
conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
run('named-checkconf',str(conf))
output=stack.enter_context(log.open('w'))
authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
stack.callback(stop,authority)
def ready():
assert authority.poll() is None,log.read_text()
response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
wait(ready)
def update(body):
nonlocal serial
serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
def cache(port=1054,upstream=1053,size=150,options=()):
logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
'--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
'--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
def cache_ready():
assert process.poll() is None,logfile.read_text()
return query('lab.test.','SOA',port).rcode()==0
wait(cache_ready);return process,logfile
def queries(name,kind='A'):
return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
yield root,update,cache,queries
@contextmanager
def named_process(root,config):
root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
run('named-checkconf',str(conf))
with log.open('w') as output:
process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
try:
def ready():
assert process.poll() is None,log.read_text()
return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
wait(ready);yield process,log
finally:stop(process)
from datetime import datetime,timedelta,timezone
import dns.flags,dns.rdataset,dns.zone
with TemporaryDirectory(prefix='xbcatm-dnssec-') as directory:
root=Path(directory);keys=root/'keys';keys.mkdir()
parent='lab.test.';child='secure.lab.test.';key_names={}
for name in (parent,child):
for role in ('KSK','ZSK'):
args=['dnssec-keygen','-q','-a','ECDSAP256SHA256','-n','ZONE','-K',str(keys)]
if role=='KSK':args+=['-f','KSK']
key_names[name,role]=run(*args,name).stdout.strip()
def base(name):return f'$ORIGIN {name}\n$TTL 60\n@ IN SOA ns.{name} hostmaster.{name} (1 60 60 3600 60)\n@ IN NS ns.{name}\nns IN A 127.0.0.1\n'
child_zone=root/'child.zone';child_zone.write_text(base(child)+'www IN A 127.0.0.2\n')
ds=run('dnssec-dsfromkey','-2',str(keys/(key_names[child,'KSK']+'.key'))).stdout.strip()
parent_body=base(parent)+'secure IN NS ns.secure.lab.test.\nns.secure IN A 127.0.0.1\n'
def sign(name,body,label,extra=()):
plain=root/(label+'.zone');signed=root/(label+'.signed');plain.write_text(body)
result=subprocess.run(['dnssec-signzone','-S','-K',str(keys),'-n','1','-o',name,'-f',str(signed),*extra,str(plain)],
cwd=root,text=True,capture_output=True,timeout=15)
assert result.returncode==0,result.stdout+result.stderr
return signed
signed_child=sign(child,child_zone.read_text(),'child-good')
signed_parent=sign(parent,parent_body+ds+'\n','parent-good')
parsed=dns.zone.from_file(str(signed_parent),origin=parent);anchor=next(key for key in parsed.get_rdataset('@','DNSKEY') if key.flags==257)
anchor_parts=anchor.to_text().split();anchor_config=' '.join(anchor_parts[:3])+' '+json.dumps(''.join(anchor_parts[3:]))
tampered=root/'child-tampered.signed';zone=dns.zone.from_file(str(signed_child),origin=child)
zone.replace_rdataset('www',dns.rdataset.from_text('IN','A',60,'127.0.0.3'));zone.to_file(str(tampered))
start=(datetime.now(timezone.utc)-timedelta(days=2)).strftime('%Y%m%d%H%M%S');end=(datetime.now(timezone.utc)-timedelta(days=1)).strftime('%Y%m%d%H%M%S')
expired=sign(child,child_zone.read_text(),'child-expired',extra=('-s',start,'-e',end,'-P'))
pieces=ds.split();pieces[-1]='0'*len(pieces[-1]);wrong_parent=sign(parent,parent_body+' '.join(pieces)+'\n','parent-wrong-ds')
insecure_parent=sign(parent,parent_body,'parent-no-ds')
outcomes={}
for label,parent_file,child_file in [('secure',signed_parent,signed_child),('tampered',signed_parent,tampered),
('expired',signed_parent,expired),('wrong_DS',wrong_parent,signed_child),('unsigned_delegation',insecure_parent,signed_child)]:
auth=root/(label+'-authority');resolver=root/(label+'-resolver');auth.mkdir();resolver.mkdir()
def options(path,port,validation,recursion):return f'''options {{ directory "{path}"; listen-on port {port} {{127.0.0.1;}};
listen-on-v6 {{none;}}; recursion {recursion}; dnssec-validation {validation}; notify no; max-cache-size 8m;
pid-file "{path}/named.pid"; lock-file "{path}/named.lock"; session-keyfile none; }}; controls {{}};'''
authority_config=options(auth,1053,'no','no')+f'''zone "{parent}" {{type primary; file "{parent_file}";}};
zone "{child}" {{type primary; file "{child_file}";}};'''
resolver_config=options(resolver,1054,'yes','yes')+f'''trust-anchors {{ "{parent}" static-key {anchor_config}; }};
zone "{parent}" {{type forward; forward only; forwarders {{127.0.0.1 port 1053;}};}};'''
with named_process(auth,authority_config),named_process(resolver,resolver_config):
q=dns.message.make_query('www.'+child,'A',want_dnssec=True)
direct=dns.query.udp(q,'127.0.0.1',port=1053,timeout=2)
assert any(rr.rdtype==dns.rdatatype.RRSIG for rr in direct.answer) and not direct.flags&dns.flags.AD
checked=dns.query.udp(q,'127.0.0.1',port=1054,timeout=8)
outcomes[label]={'authority_A':values(direct),'authority_has_RRSIG':True,'resolver_rcode':dns.rcode.to_text(checked.rcode()),
'resolver_AD':bool(checked.flags&dns.flags.AD),'resolver_A':values(checked)}
if label=='secure':assert checked.rcode()==0 and checked.flags&dns.flags.AD and values(checked)==['127.0.0.2']
elif label=='unsigned_delegation':assert checked.rcode()==0 and not checked.flags&dns.flags.AD and values(checked)==['127.0.0.2']
else:assert checked.rcode()==dns.rcode.SERVFAIL and not values(checked)
print(json.dumps({'trust_anchor':parent,'cases':outcomes,'system_clock_changed':False},sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
'/usr/bin/python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
The valid case has a signed parent DS matching the child’s key and a valid signature on www.secure.lab.test A 127.0.0.2. The authority supplies RRSIG but does not mark its direct reply AD. The separate resolver returns NOERROR, the expected address, and AD. This demonstrates the difference between serving signed data and validating that data through the configured parent-child chain.
The tampered case changes the child’s A data to 127.0.0.3 while retaining its earlier signatures. Direct authority lookup returns that changed address with RRSIG still present; the validating resolver returns SERVFAIL and no address. The test therefore checks rejection of a concrete data modification, rather than declaring data safe merely because a response contains a signature-shaped record.
Another case signs the child with deliberately past validity times. The signer generates this expired fixture without its normal post-sign verification, while resolver validation stays enabled. A third case signs the parent correctly but publishes a wrong child DS digest. Both produce SERVFAIL at the resolver, despite the authority returning the original address and RRSIG. No system-clock manipulation is used to manufacture expiry.
Finally, the signed parent publishes no DS for the child. The child still serves signed data, but the resolver returns the address without AD. This authenticated insecure delegation is accepted in the test’s policy and is distinct from the bogus tampered, expired, and wrong-DS cases. Each scenario starts fresh authority and resolver processes so prior cached results cannot mask the changed chain.
Keep signing, delegation, and validation observable
The fixture’s anchor is local; it does not test the public root or a registrar update. A production resolver can use the public root anchor and may have additional explicitly configured trust anchors. The important relationship is a valid path from an anchor the validator already trusts. DNSSEC does not require every possible deployment to derive trust from exactly one hard-coded root key.
Signing may happen offline or through managed authoritative workflows; every authoritative response does not need to be freshly signed by the server answering it. Verify the published DNSKEY and DS relationship, signature validity windows, and results through the resolvers clients actually use. Include an expected validation failure in a controlled test, since a resolver that accepts every answer can appear healthy while providing no validation.
Plan key rollover and parent coordination using the supported procedure and relevant cached lifetimes. Do not respond to a broken chain by silently disabling validation and then reporting the zone repaired. A restored plain answer is different from restored authenticated data. Compare direct authoritative contents with validating results to identify whether the fault lies in publication, delegation, signature timing, or the validator’s trusted configuration.
DNSSEC does not encrypt queries or prove that the application at an authenticated address is safe. TLS transport and application authentication protect other boundaries. The tested property is validation of signed DNS data through an authenticated delegation, with rejection of specific broken secure chains.
Sources
- RFC 4033 DNS security introduction, DNSSEC properties and secure, insecure, and bogus data, checked 2026-09-12.
- RFC 4034 DNSSEC resource records, DNSKEY, RRSIG, and DS relationships, checked 2026-09-12.
- RFC 4035 DNSSEC protocol modifications, validation, authenticated delegation, and resolver behavior, checked 2026-09-12.
- BIND 9.18.49 tools, offline key generation, zone signing, and DS generation, checked 2026-09-12.