RFC 10026 came out in July 2026 as BCP 246, "Operational Recommendations for DNSSEC Delegation Signer (DS) Automation". Its first recommendation, in section 4.1, says that whoever automates DS maintenance "MUST verify" two things before touching the parent zone: that the request is consistent "between any published CDS and CDNSKEY records" and "across all authoritative nameservers in the delegation", and "that the resulting DS record set would allow continued DNSSEC validation if deployed". If either check fails, cancel.
All of it fits in the Python standard library: raw DNS over UDP, a SHA-256 digest, and ECDSA and RSA signature checks done by hand. I wrote the check and ran it on 29 October 2026 against four zones that publish CDS records. The whole script is below in seven blocks. Paste them in order into one file and run it with a zone name, or with --simulate and a zone name.
What the RFCs actually ask for
Four documents set the rules.
RFC 7344 section 4.1 has the original acceptance rules. CDS and CDNSKEY records must sit at the child zone apex, must be signed "with a key that is represented in both the current DNSKEY and DS RRsets", and must not break the current delegation. No CDS and no CDNSKEY at all means "no change".
RFC 9975 (May 2026) defines consistency. The parent has to take the NS hostnames from its own delegation, look up "all IP addresses for each nameserver hostname" with a validating resolver, including glue, and query every one of those addresses. Each key referenced in one answer must be referenced in all the others, CDS must agree with CDNSKEY, and a delete request on one server next to a key on another is inconsistent. A NODATA answer counts as a received response. Only CDS records whose digest type is "MUST" in the "Implement for DNSSEC Delegation" column of the IANA digest registry are compared, and today that is only type 2, SHA-256. On any inconsistency the parent "MUST abort the operation".
RFC 10026 section 4.2.1 defines "would allow continued validation": the child's DNSKEY RRset must carry "a valid RRSIG signature from a key that is referenced by at least one DS record" in the new set.
RFC 8078 section 4 adds the delete signal. A CDS of 0 0 0 0 or a CDNSKEY of 0 3 0 0, each the only record in its set, asks the parent to remove the whole DS RRset.
Step 1: talk to each server directly
A recursive resolver can't do this job. It gives you one cached answer from whichever nameserver it last asked, so two servers that disagree look like one server that agrees with itself. This block builds a query with the DO bit set, sends it over UDP, retries over TCP if the answer comes back truncated, and parses the three answer sections. The largest answer this morning was 313 bytes, but a zone with several RSA keys mid-rollover can pass 1,232, which is what the TCP path is for.
# --- block 1: one DNS query over UDP, TCP if truncated import base64, hashlib, json, random, socket, struct, sys, time, urllib.request CDS, CDNSKEY, DNSKEY, DS, RRSIG, NS, A, AAAA = 59, 60, 48, 43, 46, 2, 1, 28 def wire_name(name): out = b"" for label in name.rstrip(".").lower().split("."): out += bytes([len(label)]) + label.encode() return out + b"\x00" def read_name(msg, i): labels, jumped, end = [], False, i while True: n = msg[i] if n == 0: i += 1 break if n & 0xC0 == 0xC0: # compression pointer if not jumped: end = i + 2 i, jumped = struct.unpack("!H", msg[i:i + 2])[0] & 0x3FFF, True continue labels.append(msg[i + 1:i + 1 + n].decode().lower()) i += 1 + n return ".".join(labels) + ".", (end if jumped else i) def query(server, name, rrtype, timeout=4): qid = random.randrange(65536) msg = (struct.pack("!HHHHHH", qid, 0, 1, 0, 0, 1) + wire_name(name) + struct.pack("!HH", rrtype, 1) + b"\x00" + struct.pack("!HHIH", 41, 1232, 0x8000, 0)) # EDNS0, DO bit set with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.settimeout(timeout) s.sendto(msg, (server, 53)) resp = s.recv(65535) if struct.unpack("!H", resp[2:4])[0] & 0x0200: # TC bit: retry over TCP with socket.create_connection((server, 53), timeout=timeout) as s: s.sendall(struct.pack("!H", len(msg)) + msg) size = struct.unpack("!H", s.recv(2))[0] resp = b"" while len(resp) < size: resp += s.recv(size - len(resp)) return parse(resp) def parse(msg): flags, qd, an, ns, ar = struct.unpack("!5H", msg[2:12]) i = 12 for _ in range(qd): _, i = read_name(msg, i) i += 4 sections = [] for count in (an, ns, ar): rrs = [] for _ in range(count): owner, i = read_name(msg, i) rtype, _, ttl, rdlen = struct.unpack("!HHIH", msg[i:i + 10]) i += 10 rrs.append((owner, rtype, ttl, msg[i:i + rdlen], i)) i += rdlen sections.append(rrs) return {"aa": bool(flags & 0x0400), "rcode": flags & 0xF, "msg": msg, "answer": sections[0], "authority": sections[1], "additional": sections[2]} Step 2: the delegation as the parent sees it
The NS list has to come from the parent, not from the child. A child zone can list whatever it likes in its own NS records, but the parent's referral is what sends resolvers to the child's servers in the first place. The script asks one of the parent's servers for the child's NS and reads the referral from the authority section, along with any glue. Then it looks up A and AAAA for each hostname through Google Public DNS, which validates. It also asks the same parent server for the current DS set, which it needs later.
# --- block 2: the delegation, as the parent publishes it def doh(name, rrtype): url = f"https://dns.google/resolve?name={name}&type={rrtype}" with urllib.request.urlopen(url, timeout=10) as r: j = json.load(r) return [a["data"] for a in j.get("Answer", []) if a["type"] == rrtype], j.get("AD") def delegation(zone): parent = zone.split(".", 1)[1] or "." parent_ns, _ = doh(parent, NS) parent_ip = doh(sorted(parent_ns)[0], A)[0][0] ref = query(parent_ip, zone, NS) hosts = sorted({read_name(ref["msg"], off)[0] for o, t, _, rd, off in ref["authority"] if t == NS}) glue = {(o, socket.inet_ntop(socket.AF_INET if t == A else socket.AF_INET6, rd)) for o, t, _, rd, _ in ref["additional"] if t in (A, AAAA)} ips = {} for host in hosts: found = set(doh(host, A)[0] + doh(host, AAAA)[0]) | {ip for o, ip in glue if o == host} ips[host] = sorted((ip, 6 if ":" in ip else 4) for ip in found) ds = sorted(rd for o, t, _, rd, _ in query(parent_ip, zone, DS)["answer"] if t == DS) return parent_ip, hosts, ips, ds Step 3: ask every address for CDS, CDNSKEY and DNSKEY
For each address the script asks for all three types and insists on the AA bit, so the answer comes from an authoritative server and not a forwarder. A server that times out or answers without AA is recorded, not dropped. RFC 9975 asks for consistency across responses you actually received, and says the parent should retry the silent ones later before giving up on them.
# --- block 3: ask every address for CDS, CDNSKEY and DNSKEY def rrset(resp, owner, rrtype): rdatas = sorted(rd for o, t, _, rd, _ in resp["answer"] if o == owner and t == rrtype) sigs = [rd for o, t, _, rd, _ in resp["answer"] if o == owner and t == RRSIG and struct.unpack("!H", rd[:2])[0] == rrtype] return rdatas, sigs def collect(zone, ips): owner = zone.rstrip(".").lower() + "." seen = {} for host, addrs in ips.items(): for ip, family in addrs: if family == 6: seen[(host, ip)] = "skipped: no IPv6 route from this machine" continue try: answers = {} for rrtype in (CDS, CDNSKEY, DNSKEY): resp = query(ip, zone, rrtype) if not resp["aa"] or resp["rcode"]: raise ValueError(f"aa={resp['aa']} rcode={resp['rcode']}") answers[rrtype] = rrset(resp, owner, rrtype) seen[(host, ip)] = answers except (OSError, ValueError) as e: seen[(host, ip)] = f"no usable response: {e}" return seen Step 4: turn each answer into a request
A CDNSKEY record holds the key itself. A CDS record holds what the DS record will be. To compare them, the script computes the DS digest from each CDNSKEY the way RFC 4034 section 5.1.4 defines it: SHA-256 over the owner name in wire format followed by the DNSKEY RDATA. The owner name in that hash matters, and it comes back in the results below.
Each server's answer becomes one of four things: no request, a delete request, an inconsistency, or a set of DS records. CDS records with digest types other than 2 are left out of the comparison, as RFC 9975 allows.
# --- block 4: what each server is asking the parent to publish def key_tag(rdata): # RFC 4034 Appendix B ac = sum(b if i & 1 else b << 8 for i, b in enumerate(rdata)) return (ac + ((ac >> 16) & 0xFFFF)) & 0xFFFF def ds_from_dnskey(zone, rdata): # RFC 4034 5.1.4, digest type 2 (SHA-256) digest = hashlib.sha256(wire_name(zone) + rdata).digest() return struct.pack("!HBB", key_tag(rdata), rdata[3], 2) + digest # RFC 8078 section 4: "CDS 0 0 0 0" and "CDNSKEY 0 3 0 0", each with a single zero byte of payload DELETE_CDS, DELETE_CDNSKEY = bytes.fromhex("0000000000"), bytes.fromhex("0000030000") def request(zone, answers): cds = sorted(rd for rd in answers[CDS][0] if rd[3] == 2) # only SHA-256 is compared (RFC 9975 3.1) cdnskey = answers[CDNSKEY][0] if not answers[CDS][0] and not cdnskey: return "no request (NODATA)" if DELETE_CDS in cds or DELETE_CDNSKEY in cdnskey: if set(cds) <= {DELETE_CDS} and set(cdnskey) <= {DELETE_CDNSKEY}: return "delete DS" return "inconsistent: delete signal mixed with keys" from_cdnskey = sorted(ds_from_dnskey(zone, rd) for rd in cdnskey) if cds and cdnskey and from_cdnskey != cds: return "inconsistent: CDS and CDNSKEY name different keys" return (tuple(cds), tuple(from_cdnskey)) def show_ds(rd): tag, alg, dtype = struct.unpack("!HBB", rd[:4]) return f"{tag} {alg} {dtype} {rd[4:].hex().upper()[:16]}..." Step 5: signatures, and whether the new set would validate
Every zone in this test signs with algorithm 13, ECDSA P-256 with SHA-256, which takes a few dozen lines of modular arithmetic. RSA/SHA-256 (algorithm 8) is a single pow() and a padding comparison. Anything else raises an error instead of quietly passing.
signed_by rebuilds the data the signer signed (RFC 4034 section 3.1.8.1): the RRSIG fields without the signature, then each record of the set in canonical order with the original TTL from the RRSIG. It also checks the inception and expiration times. keeps_validating is the RFC 10026 test: of the keys the new DS set points at, which ones produced a valid signature over the DNSKEY RRset? An empty answer means the update would break the zone.
# --- block 5: would the new DS set keep validation working? P256 = dict(p=2**256 - 2**224 + 2**192 + 2**96 - 1, n=0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551, g=(0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296, 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5)) def ec_add(P, Q, p=P256["p"]): if P is None or Q is None: return P or Q if P[0] == Q[0] and (P[1] + Q[Source:https://dev.to/jose_pollman_fa7c6ec43cdd/checking-cds-and-cdnskey-on-every-nameserver-before-a-ds-update-in-stdlib-python-3g45