Files
numa/site/blog/dnssec-from-scratch.html
Razvan Dimescu 2cdf90c382 fix: use SVG shield for DNSSEC badge, update blog HTML
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:43:22 +02:00

647 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Implementing DNSSEC from Scratch in Rust — Numa</title>
<meta name="description" content="Recursive resolution from root hints,
chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned
implementing DNSSEC with zero DNS libraries.">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #f5f0e8;
--bg-surface: #ece5da;
--bg-elevated: #e3dbce;
--bg-card: #faf7f2;
--amber: #c0623a;
--amber-dim: #9e4e2d;
--teal: #6b7c4e;
--teal-dim: #566540;
--violet: #64748b;
--text-primary: #2c2418;
--text-secondary: #6b5e4f;
--text-dim: #a39888;
--border: rgba(0, 0, 0, 0.08);
--border-amber: rgba(192, 98, 58, 0.22);
--font-display: 'Instrument Serif', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg-deep);
color: var(--text-primary);
font-family: var(--font-body);
font-weight: 400;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
}
/* --- Blog nav --- */
.blog-nav {
padding: 1.5rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.blog-nav a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
transition: color 0.2s;
}
.blog-nav a:hover { color: var(--amber); }
.blog-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 400;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.02em;
}
.blog-nav .wordmark:hover { color: var(--amber); }
.blog-nav .sep {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.75rem;
}
/* --- Article --- */
.article {
max-width: 720px;
margin: 0 auto;
padding: 3rem 2rem 6rem;
}
.article-header {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.article-header h1 {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.15;
margin-bottom: 1rem;
color: var(--text-primary);
}
.article-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-dim);
letter-spacing: 0.04em;
}
.article-meta a {
color: var(--amber);
text-decoration: none;
}
.article-meta a:hover { text-decoration: underline; }
/* --- Prose --- */
.article h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.8rem;
line-height: 1.2;
margin: 3rem 0 1rem;
color: var(--text-primary);
}
.article h3 {
font-family: var(--font-body);
font-weight: 600;
font-size: 1.2rem;
margin: 2rem 0 0.75rem;
color: var(--text-primary);
}
.article p {
margin-bottom: 1.25rem;
color: var(--text-secondary);
font-size: 1.05rem;
}
.article a {
color: var(--amber);
text-decoration: underline;
text-decoration-color: rgba(192, 98, 58, 0.3);
text-underline-offset: 2px;
transition: text-decoration-color 0.2s;
}
.article a:hover {
text-decoration-color: var(--amber);
}
.article strong {
color: var(--text-primary);
font-weight: 600;
}
.article ul, .article ol {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
color: var(--text-secondary);
}
.article li {
margin-bottom: 0.4rem;
font-size: 1.05rem;
}
.article blockquote {
border-left: 3px solid var(--amber);
padding: 0.75rem 1.25rem;
margin: 1.5rem 0;
background: rgba(192, 98, 58, 0.04);
border-radius: 0 4px 4px 0;
}
.article blockquote p {
color: var(--text-secondary);
font-style: italic;
margin-bottom: 0;
}
/* --- Code --- */
.article code {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--bg-elevated);
padding: 0.15em 0.4em;
border-radius: 3px;
color: var(--amber-dim);
}
.article pre {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.25rem 1.5rem;
margin: 1.5rem 0;
overflow-x: auto;
line-height: 1.55;
}
.article pre code {
background: none;
padding: 0;
border-radius: 0;
color: var(--text-primary);
font-size: 0.85rem;
}
/* --- Images --- */
.article img {
max-width: 100%;
border-radius: 6px;
border: 1px solid var(--border);
margin: 1.5rem 0;
}
/* --- Tables --- */
.article table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.95rem;
}
.article th {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
text-align: left;
padding: 0.6rem 1rem;
border-bottom: 2px solid var(--border);
}
.article td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
/* --- Footer --- */
.blog-footer {
text-align: center;
padding: 3rem 2rem;
border-top: 1px solid var(--border);
max-width: 720px;
margin: 0 auto;
}
.blog-footer a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
margin: 0 1rem;
}
.blog-footer a:hover { color: var(--amber); }
/* --- Responsive --- */
@media (max-width: 640px) {
.article { padding: 2rem 1.25rem 4rem; }
.article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; }
}
</style>
</head>
<body>
<nav class="blog-nav">
<a href="/" class="wordmark">Numa</a>
<span class="sep">/</span>
<a href="/blog/">Blog</a>
</nav>
<article class="article">
<header class="article-header">
<h1>Implementing DNSSEC from Scratch in Rust</h1>
<div class="article-meta">
March 2026 · <a href="https://dimescu.ro">Razvan Dimescu</a>
</div>
</header>
<p>In the <a href="/blog/dns-from-scratch.html">previous post</a> I
covered how DNS works at the wire level — packet format, label
compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed
packets, did useful things locally, and relayed the rest to Cloudflare
or Quad9.</p>
<p>That post ended with “recursive resolution and DNSSEC are on the
roadmap.” This post is about building both.</p>
<p>The short version: Numa now resolves from root nameservers with
iterative queries, validates the full DNSSEC chain of trust, and
cryptographically proves that non-existent domains dont exist. No
upstream dependency. No DNS libraries. Just <code>ring</code> for the
crypto primitives and a lot of RFC reading.</p>
<h2 id="why-recursive">Why recursive?</h2>
<p>A forwarding resolver trusts its upstream. When you ask Quad9 for
<code>cloudflare.com</code>, you trust that Quad9 returns the real
answer. If Quad9 lies, gets compromised, or is legally compelled to
redirect you — you have no way to know.</p>
<p>A recursive resolver doesnt trust anyone. It starts at the root
nameservers (operated by 12 independent organizations) and follows the
delegation chain: root → <code>.com</code> TLD →
<code>cloudflare.com</code> authoritative servers. Each server only
answers for its own zone. No single entity sees your full query
pattern.</p>
<p>DNSSEC adds cryptographic proof to each step. The root signs
<code>.com</code>s key. <code>.com</code> signs
<code>cloudflare.com</code>s key. <code>cloudflare.com</code> signs its
own records. If any step is tampered with, the chain breaks and Numa
rejects the response.</p>
<h2 id="the-iterative-resolution-loop">The iterative resolution
loop</h2>
<p>Recursive resolution is a misnomer — the resolver actually uses
<em>iterative</em> queries. It asks root “where is
<code>cloudflare.com</code>?”, root says “I dont know, but here are the
<code>.com</code> nameservers.” It asks <code>.com</code>, which says
“here are cloudflares nameservers.” It asks those, and gets the
answer.</p>
<pre><code>resolve(&quot;cloudflare.com&quot;, A)
→ ask 198.41.0.4 (a.root-servers.net)
&quot;try .com: ns1.gtld-servers.net (192.5.6.30)&quot; [referral + glue]
→ ask 192.5.6.30 (ns1.gtld-servers.net)
&quot;try cloudflare: ns1.cloudflare.com (173.245.58.51)&quot; [referral + glue]
→ ask 173.245.58.51 (ns1.cloudflare.com)
&quot;104.16.132.229&quot; [answer]</code></pre>
<p>The implementation (<code>src/recursive.rs</code>) is a loop with
three possible outcomes per query:</p>
<ol type="1">
<li><strong>Answer</strong> — the server knows the record. Cache it,
return it.</li>
<li><strong>Referral</strong> — the server delegates to another zone.
Extract NS records and glue (A/AAAA records for the nameservers,
included in the additional section to avoid a chicken-and-egg problem),
then query the next server.</li>
<li><strong>NXDOMAIN/REFUSED</strong> — the name doesnt exist or the
server refuses. Cache the negative result.</li>
</ol>
<p>CNAME chasing adds complexity: if you ask for
<code>www.cloudflare.com</code> and get a CNAME to
<code>cloudflare.com</code>, you need to restart resolution for the new
name. I cap this at 8 levels.</p>
<h3 id="tld-priming">TLD priming</h3>
<p>Cold-cache resolution is slow. Every query needs root → TLD →
authoritative, each with its own network round-trip. For the first query
to <code>example.com</code>, thats three serial UDP round-trips before
you get an answer.</p>
<p>TLD priming solves this. On startup, Numa queries root for NS records
of 34 common TLDs (<code>.com</code>, <code>.org</code>,
<code>.net</code>, <code>.io</code>, <code>.dev</code>, plus EU ccTLDs),
caching NS records, glue addresses, DS records, and DNSKEY records.
After priming, the first query to any <code>.com</code> domain skips
root entirely — it already knows where <code>.com</code>s nameservers
are, and already has the DNSSEC keys needed to validate the
response.</p>
<h2 id="dnssec-chain-of-trust">DNSSEC chain of trust</h2>
<p>DNSSEC doesnt encrypt DNS traffic. It <em>signs</em> it. Every DNS
record can have an accompanying RRSIG (signature) record. The resolver
verifies the signature against the zones DNSKEY, then verifies that
DNSKEY against the parent zones DS (delegation signer) record, walking
up until it reaches the root trust anchor — a hardcoded public key that
IANA publishes and the entire internet agrees on.</p>
<pre><code>cloudflare.com A 104.16.132.229
signed by → RRSIG (key_tag=34505, algo=13, signer=cloudflare.com)
verified with → DNSKEY (cloudflare.com, key_tag=34505, ECDSA P-256)
vouched for by → DS (at .com, key_tag=2371, digest=SHA-256 of cloudflare&#39;s DNSKEY)
signed by → RRSIG (key_tag=19718, signer=com)
verified with → DNSKEY (com, key_tag=19718)
vouched for by → DS (at root, key_tag=30909)
signed by → RRSIG (signer=.)
verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded)</code></pre>
<h3 id="how-keys-get-there">How keys get there</h3>
<p>The domain owner generates the DNSKEY keypair — typically their DNS
provider (Cloudflare, etc.) does this. The owner then submits the DS
record (a hash of their DNSKEY) to their registrar (Namecheap, GoDaddy),
who passes it to the registry (Verisign for <code>.com</code>). The
registry signs it into the TLD zone, and IANA signs the TLDs DS into
the root. Trust flows up; keys flow down.</p>
<p>The irony: you “own” your DNSSEC keys, but your registrar controls
whether the DS record gets published. If they remove it — by mistake, by
policy, or by court order — your DNSSEC chain breaks silently.</p>
<h3 id="the-trust-anchor">The trust anchor</h3>
<p>IANAs root KSK (Key Signing Key) has key tag 20326, algorithm 8
(RSA/SHA-256), and a 256-byte public key. It was last rolled in 2018. I
hardcode it as a <code>const</code> array — this is the one thing in the
entire system that requires out-of-band trust.</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> ROOT_KSK_PUBLIC_KEY<span class="op">:</span> <span class="op">&amp;</span>[<span class="dt">u8</span>] <span class="op">=</span> <span class="op">&amp;</span>[</span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a> <span class="dv">0x03</span><span class="op">,</span> <span class="dv">0x01</span><span class="op">,</span> <span class="dv">0x00</span><span class="op">,</span> <span class="dv">0x01</span><span class="op">,</span> <span class="dv">0xac</span><span class="op">,</span> <span class="dv">0xff</span><span class="op">,</span> <span class="dv">0xb4</span><span class="op">,</span> <span class="dv">0x09</span><span class="op">,</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... 256 bytes total</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a>]<span class="op">;</span></span></code></pre></div>
<p>When IANA rolls this key (rare — the previous key lasted from 2010 to
2018), every DNSSEC validator on the internet needs updating. For Numa,
that means a binary update. Something to watch.</p>
<p>Every DNSKEY has a key tag — a 16-bit checksum over its RDATA (RFC
4034 Appendix B). The first test I wrote: compute the root KSKs key tag
and assert it equals 20326. Instant confidence that the RDATA encoding
is correct.</p>
<h2 id="the-crypto">The crypto</h2>
<p>Numa uses <code>ring</code> for all cryptographic operations. Three
algorithms cover the vast majority of signed zones:</p>
<table>
<thead>
<tr>
<th>Algorithm</th>
<th>ID</th>
<th>Usage</th>
<th>Verify time</th>
</tr>
</thead>
<tbody>
<tr>
<td>RSA/SHA-256</td>
<td>8</td>
<td>Root, most TLDs</td>
<td>10.9 µs</td>
</tr>
<tr>
<td>ECDSA P-256</td>
<td>13</td>
<td>Cloudflare, many modern zones</td>
<td>174 ns</td>
</tr>
<tr>
<td>Ed25519</td>
<td>15</td>
<td>Newer zones</td>
<td>~200 ns</td>
</tr>
</tbody>
</table>
<h3 id="rsa-key-format-conversion">RSA key format conversion</h3>
<p>DNS stores RSA public keys in RFC 3110 format (exponent length,
exponent, modulus). <code>ring</code> expects PKCS#1 DER (ASN.1
encoded). Converting between them means writing a minimal ASN.1 encoder
with leading-zero stripping and sign-bit padding. Getting this wrong
produces keys that <code>ring</code> silently rejects — one of the
harder bugs to track down.</p>
<h3 id="ecdsa-is-simpler">ECDSA is simpler</h3>
<p>ECDSA P-256 keys in DNS are 64 bytes (x + y coordinates).
<code>ring</code> expects uncompressed point format: <code>0x04</code>
prefix + 64 bytes. One line:</p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> <span class="kw">mut</span> uncompressed <span class="op">=</span> <span class="dt">Vec</span><span class="pp">::</span>with_capacity(<span class="dv">65</span>)<span class="op">;</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>uncompressed<span class="op">.</span>push(<span class="dv">0x04</span>)<span class="op">;</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a>uncompressed<span class="op">.</span>extend_from_slice(public_key)<span class="op">;</span> <span class="co">// 64 bytes from DNS</span></span></code></pre></div>
<p>Signatures are also 64 bytes (r + s), used directly. No format
conversion needed.</p>
<h3 id="building-the-signed-data">Building the signed data</h3>
<p>RRSIG verification doesnt sign the DNS packet — it signs a canonical
form of the records. Building this correctly is the most
detail-sensitive part of DNSSEC. The signed data is:</p>
<ol type="1">
<li>RRSIG RDATA fields (type covered, algorithm, labels, original TTL,
expiration, inception, key tag, signer name) — <em>without</em> the
signature itself</li>
<li>For each record in the RRset: owner name (lowercased, uncompressed)
+ type + class + original TTL (from the RRSIG, not the records current
TTL) + RDATA length + canonical RDATA</li>
</ol>
<p>The records must be sorted by their canonical wire-format
representation. Owner names must be lowercased. The TTL must be the
<em>original</em> TTL from the RRSIG, not the decremented TTL from
caching.</p>
<p>Getting any of these details wrong — wrong TTL, wrong case, wrong
sort order, wrong RDATA encoding — produces a valid-looking but
incorrect signed data blob, and <code>ring</code> returns a signature
mismatch with no diagnostic information. I spent more time debugging
signed data construction than any other part of DNSSEC.</p>
<h2 id="proving-a-name-doesnt-exist">Proving a name doesnt exist</h2>
<p>Verifying that <code>cloudflare.com</code> has a valid A record is
one thing. Proving that <code>doesnotexist.cloudflare.com</code>
<em>doesnt</em> exist — cryptographically, in a way that cant be
forged — is harder.</p>
<h3 id="nsec">NSEC</h3>
<p>NSEC records form a chain. Each NSEC says “the next name in this zone
after me is X, and at my name these record types exist.” If you query
<code>beta.example.com</code> and the zone has
<code>alpha.example.com → NSEC → gamma.example.com</code>, the gap
proves <code>beta</code> doesnt exist — theres nothing between
<code>alpha</code> and <code>gamma</code>.</p>
<p>For NXDOMAIN proofs, RFC 4035 §5.4 requires two things: 1. An NSEC
record whose gap covers the queried name 2. An NSEC record proving no
wildcard exists at the closest encloser</p>
<p>The canonical DNS name ordering (RFC 4034 §6.1) compares labels
right-to-left, case-insensitive. <code>a.example.com</code> &lt;
<code>b.example.com</code> because at the <code>example.com</code> level
theyre equal, then <code>a</code> &lt; <code>b</code>. But
<code>z.example.com</code> &lt; <code>a.example.org</code> because
<code>.com</code> &lt; <code>.org</code> at the TLD level.</p>
<h3 id="nsec3">NSEC3</h3>
<p>NSEC3 solves NSECs zone enumeration problem — with NSEC, you can
walk the chain and discover every name in the zone. NSEC3 hashes the
names first (iterated SHA-1 with a salt), so the NSEC3 chain reveals
hashes, not names.</p>
<p>The proof is a 3-part closest encloser proof (RFC 5155 §8.4): find an
ancestor whose hash matches an NSEC3 owner, prove the next-closer name
falls within a hash range gap, and prove the wildcard at the closest
encloser also falls within a gap. All three must hold, or the denial is
rejected.</p>
<p>I cap NSEC3 iterations at 500 (RFC 9276 recommends 0). Higher
iteration counts are a DoS vector — each verification requires
<code>iterations + 1</code> SHA-1 hashes.</p>
<h2 id="making-it-fast">Making it fast</h2>
<p>Cold-cache DNSSEC validation initially required ~5 network fetches
per query (DNSKEY for each zone in the chain, plus DS records). Three
optimizations brought this down to ~1:</p>
<p><strong>TLD priming</strong> (startup) — fetch root DNSKEY + each
TLDs NS/DS/DNSKEY. After priming, the trust chain from root to any
<code>.com</code> zone is fully cached.</p>
<p><strong>Referral DS piggybacking</strong> — when a TLD server refers
you to <code>cloudflare.com</code>s nameservers, the authority section
often includes DS records for the child zone. Cache them during
resolution instead of fetching separately during validation.</p>
<p><strong>DNSKEY prefetch</strong> — before the validation loop, scan
all RRSIGs for signer zones and batch-fetch any missing DNSKEYs. This
avoids serial DNSKEY fetches inside the per-RRset verification loop.</p>
<p>Result: a cold-cache query for <code>cloudflare.com</code> with full
DNSSEC validation takes ~90ms. The TLD chain is already warm; only one
DNSKEY fetch is needed (for <code>cloudflare.com</code> itself).</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>ECDSA P-256 verify</td>
<td>174 ns</td>
</tr>
<tr>
<td>Ed25519 verify</td>
<td>~200 ns</td>
</tr>
<tr>
<td>RSA/SHA-256 verify</td>
<td>10.9 µs</td>
</tr>
<tr>
<td>DS digest (SHA-256)</td>
<td>257 ns</td>
</tr>
<tr>
<td>Key tag computation</td>
<td>2063 ns</td>
</tr>
<tr>
<td>Cold-cache validation (1 fetch)</td>
<td>~90 ms</td>
</tr>
</tbody>
</table>
<p>The network fetch dominates. The crypto is noise.</p>
<h2 id="surviving-hostile-networks">Surviving hostile networks</h2>
<p>I deployed Numa as my system DNS and switched to a different network.
Everything broke. Every query: SERVFAIL, 3-second timeout.</p>
<p>The network probe told the story: the ISP blocks outbound UDP port 53
to all servers except a handful of whitelisted public resolvers (Google,
Cloudflare). Root servers, TLD servers, authoritative servers — all
unreachable over UDP. The ISP forces you onto their DNS or a blessed
upstream. Recursive resolution is impossible.</p>
<p>Except TCP port 53 worked fine. And every DNS server is required to
support TCP (RFC 1035 section 4.2.2). The ISP apparently only filters
UDP.</p>
<p>The fix has three parts:</p>
<p><strong>TCP fallback.</strong> Every outbound query tries UDP first
(800ms timeout). If UDP fails or the response is truncated, retry
immediately over TCP. TCP uses a 2-byte length prefix before the DNS
message — trivial to implement, and it handles DNSSEC responses that
exceed the UDP payload limit.</p>
<p><strong>UDP auto-disable.</strong> After 3 consecutive UDP failures,
flip a global <code>AtomicBool</code> and skip UDP entirely — go
TCP-first for all queries. This avoids burning 800ms per hop on a
network where UDP will never work. The flag resets when the network
changes (detected via LAN IP monitoring).</p>
<p><strong>Query minimization (RFC 7816).</strong> When querying root
servers, send only the TLD — <code>com</code> instead of
<code>secret-project.example.com</code>. Root servers handle trillions
of queries and are operated by 12 organizations. Minimization reduces
what they learn from yours.</p>
<p>The result: on a network that blocks UDP:53, Numa detects the block
within the first 3 queries, switches to TCP, and resolves normally at
300-500ms per cold query. Cached queries remain 0ms. No manual config
change needed — switch networks and it adapts.</p>
<p>I wouldnt have found this without dogfooding. The code worked
perfectly on my home network. It took a real hostile network to expose
the assumption that UDP always works.</p>
<h2 id="what-i-learned">What I learned</h2>
<p><strong>DNSSEC is a verification system, not an encryption
system.</strong> It proves authenticity — this record was signed by the
zone owner. It doesnt hide what youre querying. For privacy, you still
need encrypted transport (DoH/DoT) or recursive resolution (no single
upstream).</p>
<p><strong>The hardest bugs are in data serialization, not
crypto.</strong> <code>ring</code> either verifies or it doesnt — a
binary answer. But getting the signed data blob exactly right (correct
TTL, correct case, correct sort, correct RDATA encoding for each record
type) requires extreme precision. A single wrong byte means verification
fails with no hint about whats wrong.</p>
<p><strong>Negative proofs are harder than positive proofs.</strong>
Verifying a record exists: verify one RRSIG. Proving a record doesnt
exist: find the right NSEC/NSEC3 records, verify their RRSIGs, check gap
coverage, check wildcard denial, compute hashes. The NSEC3 closest
encloser proof alone has three sub-proofs, each requiring hash
computation and range checking.</p>
<p><strong>Performance optimization is about avoiding network, not
avoiding CPU.</strong> The crypto takes nanoseconds to microseconds. The
network fetch takes tens of milliseconds. Every optimization that
matters — TLD priming, DS piggybacking, DNSKEY prefetch — is about
eliminating a round trip, not speeding up a hash.</p>
<h2 id="whats-next">Whats next</h2>
<ul>
<li><strong><a href="https://github.com/pubky/pkarr">pkarr</a>
integration</strong> — self-sovereign DNS via the Mainline BitTorrent
DHT. Your Ed25519 key is your domain. No registrar, no ICANN.</li>
<li><strong>DoT (DNS-over-TLS)</strong> — the last encrypted transport
we dont support</li>
</ul>
<p>The code is at <a
href="https://github.com/razvandimescu/numa">github.com/razvandimescu/numa</a>
— the DNSSEC validation is in <a
href="https://github.com/razvandimescu/numa/blob/main/src/dnssec.rs"><code>src/dnssec.rs</code></a>
and the recursive resolver in <a
href="https://github.com/razvandimescu/numa/blob/main/src/recursive.rs"><code>src/recursive.rs</code></a>.
MIT license.</p>
</article>
<footer class="blog-footer">
<a href="https://github.com/razvandimescu/numa">GitHub</a>
<a href="/">Home</a>
<a href="/blog/">Blog</a>
</footer>
</body>
</html>