feat: DNSSEC verified badge in dashboard query log

- Add dnssec field to QueryLogEntry, track validation status per query
- DnssecStatus::as_str() for API serialization
- Dashboard shows green checkmark next to DNSSEC-verified responses
- Blog post: add "How keys get there" section, transport resilience section,
  trim code blocks, update What's Next

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-27 21:32:29 +02:00
parent 5b2cc874a1
commit 5f43d262d6
6 changed files with 65 additions and 87 deletions

View File

@@ -62,6 +62,12 @@ cloudflare.com A 104.16.132.229
verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded) verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded)
``` ```
### How keys get there
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 `.com`). The registry signs it into the TLD zone, and IANA signs the TLD's DS into the root. Trust flows up; keys flow down.
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.
### The trust anchor ### The trust anchor
IANA's 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 `const` array — this is the one thing in the entire system that requires out-of-band trust. IANA's 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 `const` array — this is the one thing in the entire system that requires out-of-band trust.
@@ -75,30 +81,7 @@ const ROOT_KSK_PUBLIC_KEY: &[u8] = &[
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. 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.
### Key tag computation 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 KSK's key tag and assert it equals 20326. Instant confidence that the RDATA encoding is correct.
Every DNSKEY has a key tag — a 16-bit identifier computed per RFC 4034 Appendix B. It's a simple checksum over the DNSKEY RDATA (flags + protocol + algorithm + public key), summing 16-bit words with carry:
```rust
pub fn compute_key_tag(flags: u16, protocol: u8, algorithm: u8, public_key: &[u8]) -> u16 {
let mut rdata = Vec::with_capacity(4 + public_key.len());
rdata.push((flags >> 8) as u8);
rdata.push((flags & 0xFF) as u8);
rdata.push(protocol);
rdata.push(algorithm);
rdata.extend_from_slice(public_key);
let mut ac: u32 = 0;
for (i, &byte) in rdata.iter().enumerate() {
if i % 2 == 0 { ac += (byte as u32) << 8; }
else { ac += byte as u32; }
}
ac += (ac >> 16) & 0xFFFF;
(ac & 0xFFFF) as u16
}
```
The first test I wrote: compute the root KSK's key tag and assert it equals 20326. Instant confidence that the RDATA encoding is correct.
## The crypto ## The crypto
@@ -112,28 +95,7 @@ Numa uses `ring` for all cryptographic operations. Three algorithms cover the va
### RSA key format conversion ### RSA key format conversion
DNS stores RSA public keys in RFC 3110 format: exponent length (1 or 3 bytes), exponent, modulus. `ring` expects PKCS#1 DER (ASN.1 encoded). Converting between them means writing a minimal ASN.1 encoder: DNS stores RSA public keys in RFC 3110 format (exponent length, exponent, modulus). `ring` 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 `ring` silently rejects — one of the harder bugs to track down.
```rust
fn rsa_dnskey_to_der(public_key: &[u8]) -> Option<Vec<u8>> {
// Parse RFC 3110: [exp_len] [exponent] [modulus]
let (exp_len, exp_start) = if public_key[0] == 0 {
let len = u16::from_be_bytes([public_key[1], public_key[2]]) as usize;
(len, 3)
} else {
(public_key[0] as usize, 1)
};
let exponent = &public_key[exp_start..exp_start + exp_len];
let modulus = &public_key[exp_start + exp_len..];
// Build ASN.1 DER: SEQUENCE { INTEGER modulus, INTEGER exponent }
let mod_der = asn1_integer(modulus);
let exp_der = asn1_integer(exponent);
// ... wrap in SEQUENCE tag + length
}
```
The `asn1_integer` function handles leading-zero stripping (DER integers must be minimal) and sign-bit padding (high bit set means negative in ASN.1, so positive numbers need a `0x00` prefix). Getting this wrong produces keys that `ring` silently rejects — one of the harder bugs to track down.
### ECDSA is simpler ### ECDSA is simpler
@@ -176,29 +138,7 @@ The canonical DNS name ordering (RFC 4034 §6.1) compares labels right-to-left,
NSEC3 solves NSEC's 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. NSEC3 solves NSEC's 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.
The proof is a 3-part closest encloser proof (RFC 5155 §8.4): 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.
1. **Closest encloser** — find an ancestor of the queried name whose hash exactly matches an NSEC3 owner
2. **Next closer** — the name one label longer than the closest encloser must fall within an NSEC3 hash range (proving it doesn't exist)
3. **Wildcard denial** — the wildcard at the closest encloser (`*.closest_encloser`) must also fall within an NSEC3 hash range
```rust
// Pre-compute hashes for all ancestors
for i in 0..labels.len() {
let name: String = labels[i..].join(".");
ancestor_hashes.push(nsec3_hash(&name, algorithm, iterations, salt));
}
// Walk from longest candidate: is this the closest encloser?
for i in 1..labels.len() {
let ce_hash = &ancestor_hashes[i];
if !decoded.iter().any(|(oh, _)| oh == ce_hash) { continue; } // (1)
let nc_hash = &ancestor_hashes[i - 1];
if !nsec3_any_covers(&decoded, nc_hash) { continue; } // (2)
let wc = format!("*.{}", labels[i..].join("."));
let wc_hash = nsec3_hash(&wc, algorithm, iterations, salt)?;
if nsec3_any_covers(&decoded, &wc_hash) { proven = true; break; } // (3)
}
```
I cap NSEC3 iterations at 500 (RFC 9276 recommends 0). Higher iteration counts are a DoS vector — each verification requires `iterations + 1` SHA-1 hashes. I cap NSEC3 iterations at 500 (RFC 9276 recommends 0). Higher iteration counts are a DoS vector — each verification requires `iterations + 1` SHA-1 hashes.
@@ -225,6 +165,26 @@ Result: a cold-cache query for `cloudflare.com` with full DNSSEC validation take
The network fetch dominates. The crypto is noise. The network fetch dominates. The crypto is noise.
## Surviving hostile networks
I deployed Numa as my system DNS and switched to a different network. Everything broke. Every query: SERVFAIL, 3-second timeout.
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.
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.
The fix has three parts:
**TCP fallback.** 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.
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` 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).
**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours.
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.
I wouldn't 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.
## What I learned ## What I learned
**DNSSEC is a verification system, not an encryption system.** It proves authenticity — this record was signed by the zone owner. It doesn't hide what you're querying. For privacy, you still need encrypted transport (DoH/DoT) or recursive resolution (no single upstream). **DNSSEC is a verification system, not an encryption system.** It proves authenticity — this record was signed by the zone owner. It doesn't hide what you're querying. For privacy, you still need encrypted transport (DoH/DoT) or recursive resolution (no single upstream).
@@ -237,10 +197,7 @@ The network fetch dominates. The crypto is noise.
## What's next ## What's next
Numa now has 13 feature layers, from basic DNS forwarding through full recursive DNSSEC resolution. The immediate roadmap: - **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Your Ed25519 key is your domain. No registrar, no ICANN.
- **DoT (DNS-over-TLS)** — the last encrypted transport we don't support - **DoT (DNS-over-TLS)** — the last encrypted transport we don't support
- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Ed25519-signed DNS records published without a registrar.
- **Global `.numa` names** — human-readable names backed by DHT, not ICANN
The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa). MIT license. The entire DNSSEC implementation is in [`src/dnssec.rs`](https://github.com/razvandimescu/numa/blob/main/src/dnssec.rs) (~1,600 lines) and [`src/recursive.rs`](https://github.com/razvandimescu/numa/blob/main/src/recursive.rs) (~600 lines). The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DNSSEC validation is in [`src/dnssec.rs`](https://github.com/razvandimescu/numa/blob/main/src/dnssec.rs) and the recursive resolver in [`src/recursive.rs`](https://github.com/razvandimescu/numa/blob/main/src/recursive.rs). MIT license.

View File

@@ -766,7 +766,7 @@ function applyLogFilter() {
<td>${e.query_type}</td> <td>${e.query_type}</td>
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td> <td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
<td><span class="path-tag ${e.path}">${e.path}</span></td> <td><span class="path-tag ${e.path}">${e.path}</span></td>
<td>${e.rescode}</td> <td>${e.dnssec === 'secure' ? '<span title="DNSSEC verified" style="color:var(--emerald);cursor:default;font-size:0.7rem;">&#x2714;</span> ' : ''}${e.rescode}</td>
<td>${e.latency_ms.toFixed(1)}ms</td> <td>${e.latency_ms.toFixed(1)}ms</td>
</tr>`; </tr>`;
}).join(''); }).join('');

View File

@@ -153,6 +153,7 @@ struct QueryLogResponse {
path: String, path: String,
rescode: String, rescode: String,
latency_ms: f64, latency_ms: f64,
dnssec: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -461,6 +462,7 @@ async fn query_log(
path: e.path.as_str().to_string(), path: e.path.as_str().to_string(),
rescode: e.rescode.as_str().to_string(), rescode: e.rescode.as_str().to_string(),
latency_ms: e.latency_us as f64 / 1000.0, latency_ms: e.latency_us as f64 / 1000.0,
dnssec: e.dnssec.as_str().to_string(),
} }
}) })
.collect() .collect()

View File

@@ -14,6 +14,17 @@ pub enum DnssecStatus {
Indeterminate, Indeterminate,
} }
impl DnssecStatus {
pub fn as_str(&self) -> &'static str {
match self {
DnssecStatus::Secure => "secure",
DnssecStatus::Insecure => "insecure",
DnssecStatus::Bogus => "bogus",
DnssecStatus::Indeterminate => "indeterminate",
}
}
}
struct CacheEntry { struct CacheEntry {
packet: DnsPacket, packet: DnsPacket,
inserted_at: Instant, inserted_at: Instant,

View File

@@ -10,7 +10,7 @@ use tokio::net::UdpSocket;
use crate::blocklist::BlocklistStore; use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer; use crate::buffer::BytePacketBuffer;
use crate::cache::DnsCache; use crate::cache::{DnsCache, DnssecStatus};
use crate::config::{UpstreamMode, ZoneMap}; use crate::config::{UpstreamMode, ZoneMap};
use crate::forward::{forward_query, Upstream}; use crate::forward::{forward_query, Upstream};
use crate::header::ResultCode; use crate::header::ResultCode;
@@ -77,12 +77,12 @@ pub async fn handle_query(
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
// Each lock is scoped to avoid holding MutexGuard across await points. // Each lock is scoped to avoid holding MutexGuard across await points.
let (response, path) = { let (response, path, dnssec) = {
let override_record = ctx.overrides.read().unwrap().lookup(&qname); let override_record = ctx.overrides.read().unwrap().lookup(&qname);
if let Some(record) = override_record { if let Some(record) = override_record {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers.push(record); resp.answers.push(record);
(resp, QueryPath::Overridden) (resp, QueryPath::Overridden, DnssecStatus::Indeterminate)
} else if !ctx.proxy_tld_suffix.is_empty() } else if !ctx.proxy_tld_suffix.is_empty()
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
{ {
@@ -120,7 +120,7 @@ pub async fn handle_query(
ttl: 300, ttl: 300,
}), }),
} }
(resp, QueryPath::Local) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) { } else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
match qtype { match qtype {
@@ -135,20 +135,20 @@ pub async fn handle_query(
ttl: 60, ttl: 60,
}), }),
} }
(resp, QueryPath::Blocked) (resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) { } else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers = records.clone(); resp.answers = records.clone();
(resp, QueryPath::Local) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else { } else {
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
if let Some((cached, cached_dnssec)) = cached { if let Some((cached, cached_dnssec)) = cached {
let mut resp = cached; let mut resp = cached;
resp.header.id = query.header.id; resp.header.id = query.header.id;
if cached_dnssec == crate::cache::DnssecStatus::Secure { if cached_dnssec == DnssecStatus::Secure {
resp.header.authed_data = true; resp.header.authed_data = true;
} }
(resp, QueryPath::Cached) (resp, QueryPath::Cached, cached_dnssec)
} else if ctx.upstream_mode == UpstreamMode::Recursive { } else if ctx.upstream_mode == UpstreamMode::Recursive {
match crate::recursive::resolve_recursive( match crate::recursive::resolve_recursive(
&qname, &qname,
@@ -159,7 +159,7 @@ pub async fn handle_query(
) )
.await .await
{ {
Ok(resp) => (resp, QueryPath::Recursive), Ok(resp) => (resp, QueryPath::Recursive, DnssecStatus::Indeterminate),
Err(e) => { Err(e) => {
error!( error!(
"{} | {:?} {} | RECURSIVE ERROR | {}", "{} | {:?} {} | RECURSIVE ERROR | {}",
@@ -168,6 +168,7 @@ pub async fn handle_query(
( (
DnsPacket::response_from(&query, ResultCode::SERVFAIL), DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError, QueryPath::UpstreamError,
DnssecStatus::Indeterminate,
) )
} }
} }
@@ -180,7 +181,7 @@ pub async fn handle_query(
match forward_query(&query, &upstream, ctx.timeout).await { match forward_query(&query, &upstream, ctx.timeout).await {
Ok(resp) => { Ok(resp) => {
ctx.cache.write().unwrap().insert(&qname, qtype, &resp); ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded) (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
} }
Err(e) => { Err(e) => {
error!( error!(
@@ -190,6 +191,7 @@ pub async fn handle_query(
( (
DnsPacket::response_from(&query, ResultCode::SERVFAIL), DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError, QueryPath::UpstreamError,
DnssecStatus::Indeterminate,
) )
} }
} }
@@ -201,6 +203,7 @@ pub async fn handle_query(
let mut response = response; let mut response = response;
// DNSSEC validation (recursive/forwarded responses only) // DNSSEC validation (recursive/forwarded responses only)
let mut dnssec = dnssec;
if ctx.dnssec_enabled && path == QueryPath::Recursive { if ctx.dnssec_enabled && path == QueryPath::Recursive {
let (status, vstats) = let (status, vstats) =
crate::dnssec::validate_response(&response, &ctx.cache, &ctx.root_hints).await; crate::dnssec::validate_response(&response, &ctx.cache, &ctx.root_hints).await;
@@ -216,11 +219,13 @@ pub async fn handle_query(
vstats.ds_fetches, vstats.ds_fetches,
); );
if status == crate::cache::DnssecStatus::Secure { dnssec = status;
if status == DnssecStatus::Secure {
response.header.authed_data = true; response.header.authed_data = true;
} }
if status == crate::cache::DnssecStatus::Bogus && ctx.dnssec_strict { if status == DnssecStatus::Bogus && ctx.dnssec_strict {
response = DnsPacket::response_from(&query, ResultCode::SERVFAIL); response = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
} }
@@ -292,6 +297,7 @@ pub async fn handle_query(
path, path,
rescode: response.header.rescode, rescode: response.header.rescode,
latency_us: elapsed.as_micros() as u64, latency_us: elapsed.as_micros() as u64,
dnssec,
}); });
Ok(()) Ok(())

View File

@@ -2,6 +2,7 @@ use std::collections::VecDeque;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::time::SystemTime; use std::time::SystemTime;
use crate::cache::DnssecStatus;
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::question::QueryType; use crate::question::QueryType;
use crate::stats::QueryPath; use crate::stats::QueryPath;
@@ -14,6 +15,7 @@ pub struct QueryLogEntry {
pub path: QueryPath, pub path: QueryPath,
pub rescode: ResultCode, pub rescode: ResultCode,
pub latency_us: u64, pub latency_us: u64,
pub dnssec: DnssecStatus,
} }
pub struct QueryLog { pub struct QueryLog {