The Metadata Leak Problem

When you visit https://secret-stuff.example.com, your HTTPS traffic is encrypted. But the connection process leaks metadata at multiple points:

Step 1: DNS Resolution
 Client → ISP DNS: "What's the IP of secret-stuff.example.com?"
 ISP DNS → Client: "It's 203.0.113.42"
 [LEAKED: The domain you're visiting]

Step 2: TCP Connection
 Client → 203.0.113.42: SYN
 [LEAKED: The IP you're connecting to]

Step 3: TLS Handshake
 Client → Server: ClientHello { SNI: "secret-stuff.example.com" }
 [LEAKED: The domain again, in cleartext]

Step 4: Encrypted Application Data
 [PROTECTED: The actual HTTP request and response]

Three out of four steps leak the domain you're visiting. Encrypted DNS only fixes step 1. ECH fixes step 3. Step 2 is fundamentally hard (requires a proxy, VPN, or Tor).

DNS-over-HTTPS (DoH): The Basics

DoH wraps DNS queries in standard HTTPS requests, using application/dns-message content type:

# DoH query using curl (wire format)
curl -s -H 'Accept: application/dns-message' \
 'https://cloudflare-dns.com/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' \
 | python3 -c "import sys; data=sys.stdin.buffer.read(); print(data.hex())"

# DoH query using JSON format (Cloudflare/Google extension)
curl -s -H 'Accept: application/dns-json' \
 'https://cloudflare-dns.com/dns-query?name=example.com&type=A' | jq .

# Response:
{
 "Status": 0,
 "TC": false,
 "RD": true,
 "RA": true,
 "AD": true,
 "CD": false,
 "Question": [{"name": "example.com", "type": 1}],
 "Answer": [
 {"name": "example.com", "type": 1, "TTL": 3600,
 "data": "93.184.216.34"}
 ]
}

Setting up a local DoH resolver with dnscrypt-proxy:

# /etc/dnscrypt-proxy/dnscrypt-proxy.toml
listen_addresses = ['127.0.0.1:53', '[::1]:53']
server_names = ['cloudflare', 'cloudflare-ipv6']
doh_servers = true
require_dnssec = true
require_nofilter = true
require_nolog = true

# Connection settings
timeout = 5000
keepalive = 30
cert_refresh_delay = 240

# Caching
cache = true
cache_size = 4096
cache_min_ttl = 2400
cache_max_ttl = 86400
cache_neg_min_ttl = 60
cache_neg_max_ttl = 600

# Oblivious DoH relays
[anonymized_dns]
routes = [
 { server_name='cloudflare', via=['anon-relay-1', 'anon-relay-2'] }
]

DNS-over-TLS (DoT): The Enterprise Alternative

DoT uses TLS directly on port 853, without the HTTP layer:

import ssl
import socket
import struct

def dot_query(domain, resolver='1.1.1.1', port=853):
 # Build DNS query
 query = build_dns_query(domain, qtype='A')

 # Wrap with 2-byte length prefix (TCP DNS framing)
 framed = struct.pack('!H', len(query)) + query

 # TLS connection
 ctx = ssl.create_default_context()
 ctx.check_hostname = True
 ctx.verify_mode = ssl.CERT_REQUIRED

 with socket.create_connection((resolver, port)) as sock:
 with ctx.wrap_socket(sock, server_hostname='cloudflare-dns.com') as tls:
 tls.sendall(framed)

 # Read response length
 length_data = tls.recv(2)
 length = struct.unpack('!H', length_data)[0]

 # Read response
 response = tls.recv(length)
 return parse_dns_response(response)

DoT vs DoH trade-offs:

 DoH (port 443) DoT (port 853)
Indistinguishable Yes (looks like No (dedicated port)
from HTTPS traffic normal browsing)
Blockable by FW Hard (blocks all Easy (block port 853)
 HTTPS)
Enterprise control Hard to manage Easy to detect/redirect
Performance HTTP/2 multiplexing Dedicated TLS, less
 with other traffic overhead per query
Padding support Via HTTP/2 frames Via EDNS0 padding

DNSSEC: Integrity, Not Confidentiality

DNSSEC adds cryptographic signatures to DNS records. It does NOT encrypt anything, it only proves the response wasn't modified in transit.

# Check DNSSEC validation with dig
dig +dnssec example.com

# Look for:
# - 'ad' flag in header (Authenticated Data)
# - RRSIG records (signatures)
# - DNSKEY records (zone signing keys)

# Verify the chain of trust manually
dig +trace +dnssec example.com

# Chain: root (.) → .com → example.com
# Each level signs the delegation to the next
# The root zone's key is the trust anchor (built into resolvers)

DNSSEC validation on your local resolver:

# Using unbound as a validating resolver
# /etc/unbound/unbound.conf
server:
 interface: 127.0.0.1
 port: 5353
 do-not-query-localhost: no

 # DNSSEC validation
 auto-trust-anchor-file: "/var/lib/unbound/root.key"
 val-clean-additional: yes
 val-permissive-mode: no # Strict: fail on DNSSEC errors
 harden-dnssec-stripped: yes

 # Forward to DoH resolver
 forward-zone:
 name: "."
 forward-tls-upstream: yes
 forward-addr: 1.1.1.1@853#cloudflare-dns.com
 forward-addr: 1.0.0.1@853#cloudflare-dns.com

Encrypted Client Hello (ECH): The Missing Piece

ECH encrypts the entire ClientHello (including SNI) using a public key published in the DNS record of the server. Here's how it works:

Without ECH:
 Client → Server: ClientHello {
 SNI: "secret-stuff.example.com" ← VISIBLE to on-path observer
 supported_ciphers: [...]
 supported_groups: [...]
 }

With ECH:
 1. Client fetches HTTPS DNS record for secret-stuff.example.com
 → Contains ECH config with public key

 2. Client → Server: ClientHello {
 SNI: "cloudflare-ech.com" ← "Outer" SNI (public-facing domain)
 encrypted_client_hello: {
 // Encrypted with server's ECH public key:
 inner_SNI: "secret-stuff.example.com"
 supported_ciphers: [...]
 }
 }

 Observer sees: connection to "cloudflare-ech.com"
 Server decrypts: actual destination is "secret-stuff.example.com"

The DNS record looks like:

# Fetch ECH configuration via DNS HTTPS record
dig +short HTTPS cloudflare.com
# 1 . alpn="h3,h2" ipv4hint=104.16.132.229 ech=AEX+...

# The ech= parameter contains:
# - KEM (Key Encapsulation Mechanism) ID
# - Public key for encrypting the inner ClientHello
# - Symmetric cipher suite
# - Maximum name length

Testing ECH in your browser:

// Check if ECH is active on a connection
// In Firefox: about:networking#dns
// Look for "hasECH: true" in the connection info

// Programmatic check (from web page):
// Currently no standard API, but you can test against
// defo.ie/ech-check.php or similar test servers

// Firefox about:config settings:
// network.dns.echconfig.enabled = true
// network.dns.use_https_rr_as_altsvc = true

Oblivious DoH (ODoH): Trust Nobody

Standard DoH has a trust problem: you're replacing your ISP as the entity that sees your DNS queries with your DoH resolver (Cloudflare, Google, etc.). ODoH fixes this:

Standard DoH:
 Client →→ Resolver
 Resolver sees: client IP + query
 Problem: resolver knows everything

Oblivious DoH:
 Client → Relay → Resolver
 Relay sees: client IP, but query is encrypted to resolver
 Resolver sees: query content, but not client IP (sees relay IP)
 Nobody sees both!

The protocol works like this:

1. Client fetches resolver's ODoH target key (via DNS)
2. Client encrypts DNS query with resolver's public key
3. Client sends encrypted query to relay (standard HTTPS POST)
4. Relay forwards encrypted blob to resolver (can't read it)
5. Resolver decrypts, resolves, encrypts response
6. Response flows back: resolver → relay → client
# Configure ODoH with dnscrypt-proxy
[anonymized_dns]
routes = [
 { server_name = 'cloudflare', via = ['odoh-relay-crypto-sx'] },
 { server_name = 'cloudflare', via = ['odoh-relay-amsterdam'] }
]

# Performance comparison:
# Standard DNS (UDP): ~5 ms
# DoH (direct): ~25 ms
# DoT (direct): ~20 ms
# ODoH (via relay): ~35-40 ms
# Double-relay ODoH: ~55-70 ms

The Complete Privacy Stack

Here is a recommended complete privacy stack configuration:

# Stack:
# 1. dnscrypt-proxy (local port 53) → ODoH to Cloudflare via relay
# 2. DNSSEC validation enabled
# 3. Firefox with ECH enabled
# 4. HTTPS-only mode

# /etc/dnscrypt-proxy/dnscrypt-proxy.toml
listen_addresses = ['127.0.0.1:53']
server_names = ['cloudflare']
doh_servers = true
require_dnssec = true

[anonymized_dns]
routes = [
 { server_name='cloudflare', via=['anon-relay-nl'] }
]

# Firefox about:config
network.dns.echconfig.enabled = true
network.dns.use_https_rr_as_altsvc = true
network.trr.mode = 3 # DoH only, no fallback to system DNS
network.trr.uri = https://127.0.0.1:3000/dns-query # Local proxy
dom.security.https_only_mode = true

Honest Privacy Analysis

Here is an honest assessment of what each layer actually provides:

Threat Plain DNS DoH/DoT + DNSSEC + ECH + ODoH
ISP sees domains Yes No No No No
Café WiFi sniffing Yes No No No No
DNS spoofing Yes No No No No
Resolver sees queries Yes Yes Yes Yes No
SNI leaks domain Yes Yes Yes No No
Destination IP visible Yes Yes Yes Yes Yes

That last row is the elephant in the room. Even with the full stack, anyone who can see your network traffic knows the IP address you're connecting to. For shared hosting (like Cloudflare), the IP maps to millions of sites, so it's not very revealing. For sites on dedicated IPs, the destination IP alone identifies the site.

For the last remaining leak, you need a proxy, VPN, or Tor. There's no DNS-level solution for it.

The privacy stack described above is about raising the floor, making passive surveillance significantly harder without dramatically impacting performance. It's not about achieving perfect anonymity. If you need that, you need Tor. But for everyday privacy against ISP monitoring, corporate network inspection, and café WiFi sniffing, DoH + ECH + ODoH gets you about 90% of the way there with under 50ms of added latency.