Back to guides
Reference

Anatomy of a Hardened DNS Zone.

DNS records are public. Anyone can query the dnsdoh.art zone and see exactly how it is protected, so this guide simply walks through it: the real record set of a domain that runs its own encrypted resolver, layer by layer, with the dig command for each claim. Nothing here is a mock-up. If you run a domain, every layer applies to yours too.

By Ozy-666, creator and operator of dnsdoh.art · Published · 16 min read
Six attempts, six refusals THE ATTEMPT REFUSED BY THE ZONE Forged DNS answer in transit a faked reply races the real one 1 · DNSSEC signature chain unsigned forgery fails validation, discarded Impostor server, different key a look-alike endpoint answers instead 2 · DANE pin, four transports key hash does not match the signed TLSA pin Reading the name off the handshake an observer watches the TLS ClientHello 3 · ECH, key rotated monthly inner hello encrypted to the key in DNS Device stuck on plain port-53 DNS a router hands out only the bare IP 4 · DDR discovery, verified designates DoH/DoT/DoQ, checked against the cert Email forged from @dnsdoh.art the domain in a From: header, for free 5 · Null MX + SPF -all + DMARC receivers are told to reject, in three records Certificate from some other CA issuance requested for the name elsewhere 6 · CAA: one permitted issuer every CA except letsencrypt.org must refuse Each refusal is one or two public records. The complete record set, full length, is in the table below; the six sections after it show how each one is deployed and verified.

The zone as a set of refusals. Every record behind them is public and shown in full below.

A domain's zone is its public face: what it signs, what it pins, what it refuses to do. Here is ours, read top to bottom.

The record set, in full

Before the layer-by-layer tour, the complete hardening record set as it stands today, untruncated. This is not an example zone; it is the production one, and every value below can be re-fetched with dig while you read:

Name Type TTL Value
dnsdoh.artA300194.180.189.33
dnsdoh.artDS39388 13 2 909C791465A1BC8E827673F22E0B6AE66F65274DEF5B51A9D9E784678DD9C40C
dnsdoh.artDNSKEY300257 3 13 3Rom9mkvFcUMlkObJTnH5dAGYTLTm+GzPK/HMBniX1VKquy59+BalHZmDT+Zy1xfhHRZCHZN3jEk9zAUP0Cn5g==
dnsdoh.artDNSKEY300256 3 13 C3S+9PJU0i0EEbo5V/CMiIiGLsIJ1hqDR8am9ConRRMHMP246ZHRHYS71/BpVn2IH/bf5Lbc7NXlmKVxXlla4g==
_443._tcp.dnsdoh.artTLSA3003 1 1 68a92c99f947133f4bf4bc3f0a8cad82db0d7f51a923b09674420c5bf1be5c2f
_443._udp.dnsdoh.artTLSA3003 1 1 68a92c99f947133f4bf4bc3f0a8cad82db0d7f51a923b09674420c5bf1be5c2f
_853._tcp.dnsdoh.artTLSA3003 1 1 68a92c99f947133f4bf4bc3f0a8cad82db0d7f51a923b09674420c5bf1be5c2f
_853._udp.dnsdoh.artTLSA3003 1 1 68a92c99f947133f4bf4bc3f0a8cad82db0d7f51a923b09674420c5bf1be5c2f
dnsdoh.artHTTPS3001 . alpn="h3,h2,http/1.1" ech=AEH+DQA9AQAgACDdYD1CXOTJUqBDePCp+w88a23bhCo+WVCq4WLwyQuhaQAIAAEAAQABAAMACmRuc2RvaC5hcnQAAA==
dnsdoh.artMX36000 .
dnsdoh.artTXT300"v=spf1 -all"
_dmarc.dnsdoh.artTXT3600"v=DMARC1; p=reject;"
dnsdoh.artCAA3000 issue "letsencrypt.org"

Three reading notes. The DS record lives in the .art registry rather than in this zone, which is why it has no TTL here - it is the parent's record about us. The ech= value is a snapshot: it rotates monthly (the mechanism, including the rotation script, is published in layer 3). And the short 300-second TTLs on the operational records are deliberate: they let a rotation or an emergency change propagate in minutes, at the cost of a few extra queries from resolvers.

The zone also carries the ordinary service records every domain has - a www alias, verification TXT entries and the NS/SOA set of its DNS host. They are queryable like everything else but do no hardening work, so the tour skips them.

Layer 1 - Identity: DNSSEC with ECDSA P-256

Everything else in this guide depends on one property: an answer from this zone can be checked, not just believed. DNSSEC provides that. The zone's records are signed, the signing key is published as a DNSKEY, and a hash of that key sits one level up, in the .art registry, as a DS record. A validating resolver walks that chain from the root down; a forged answer fails the signature check and is discarded. What DNSSEC is covers the mechanism in full; here is what it looks like deployed:

# the DS record at the parent (.art registry) anchors the chain
$ dig +short dnsdoh.art DS
39388 13 2 909C791465A1BC8E827673F22E0B6AE66F65274DEF5B51A9D9E78467 8DD9C40C

# the zone's own keys: 257 = key-signing key, 256 = zone-signing key
$ dig +short dnsdoh.art DNSKEY
257 3 13 3Rom9mkvFcUMlkObJTnH5dAGYTLTm+GzPK/HMBniX1VKquy59+B...
256 3 13 C3S+9PJU0i0EEbo5V/CMiIiGLsIJ1hqDR8am9ConRRMHMP246ZH...

# a validating resolver sets the ad (authenticated data) flag
$ dig +dnssec dnsdoh.art A @1.1.1.1 | grep ';; flags'
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

The 13 in every record is the algorithm: ECDSA P-256 with SHA-256. The zone originally signed with RSA (algorithm 8) and was migrated: ECDSA signatures are about a quarter the size of comparable RSA ones, which keeps signed responses compact - smaller answers fit in UDP without truncation and give amplification-friendly bulk no room to grow. The migration order matters, and it is unforgiving: remove the old DS at the registrar, wait until it expires from the parent and every resolver cache, only then swap the keys and publish the new DS. Change keys while any resolver still holds the old DS and that resolver returns SERVFAIL for the whole zone until its cache expires.

One honest limitation: DNSSEC proves the answer is what the zone owner published. It does not hide the query or the answer from anyone watching. Privacy is a transport problem, which is where the next two layers come in.

Layer 2 - Transport pinning: DANE on all four transports

This service answers encrypted DNS on four transports: DoH and DoH3 on port 443 (TCP and QUIC), DoT and DoQ on port 853 (TCP and QUIC). Each transport gets a TLSA record - a DANE pin that puts a hash of the server's public key into the signed zone itself:

# DoH and DoH3 (port 443, TCP + QUIC), DoT and DoQ (port 853, TCP + QUIC)
$ dig +short _443._tcp.dnsdoh.art TLSA
3 1 1 68A92C99F947133F4BF4BC3F0A8CAD82DB0D7F51A923B09674420C5B F1BE5C2F
# _443._udp, _853._tcp and _853._udp return the same pin

The three numbers are a policy: 3 means the pin describes the server's own certificate rather than a CA (DANE-EE), 1 means it pins the public key, not the whole certificate, and the second 1 means the value is a SHA-256 hash. All four records carry the same hash because all four transports present the same key.

Generating the pin is three piped OpenSSL commands: extract the public key from the certificate, convert it to DER (the binary SPKI structure the hash is defined over), and hash it. Run against your own certificate, the output is exactly the hex string that goes into the 3 1 1 record:

# SPKI SHA-256 pin ("3 1 1") from the certificate you serve
$ openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -pubkey \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -hex
SHA2-256(stdin)= 68a92c99f947133f4bf4bc3f0a8cad82db0d7f51a923b09674420c5bf1be5c2f

# the same hash must come back from the zone - if it does not, the pin is stale
$ dig +short _443._tcp.example.com TLSA

Those same three commands run inside this service's renewal watchdog: after every certificate event it recomputes the SPKI hash from the live certificate and checks it against all four TLSA records, so a drifted pin is caught by the operator before a DANE-aware client ever sees a mismatch.

Pinning the key rather than the certificate is what makes this survivable in practice. The certificate here is a short-lived Let's Encrypt one, valid about six and a half days and renewed automatically every few days using ARI, the CA's renewal-window API. That would be an operational nightmare for a certificate-hash pin - the zone would need an update at every renewal. But renewals here are configured to reuse the same key pair, so the certificate changes and the pin does not. The one event that requires care is an actual key rotation: publish the new pin alongside the old one, wait out the TTL, rotate the key, then drop the old pin.

Who checks these records? Not browsers - mainstream ones never adopted DANE, the CA plus CAA model won there. But DANE-aware DNS clients can refuse a connection whose key does not match the pin, and for everyone else the records still do quiet work: they are a public, signed, timestamped commitment to a specific key. If this service ever presented a different key, the mismatch would be visible to anyone who looked.

Layer 3 - Handshake privacy: the HTTPS record and ECH

The apex HTTPS record is the newest workhorse in the zone. A client that queries it before connecting learns two things at once - which protocols the server speaks, and the public key for encrypting the first packet of the conversation:

$ dig +short dnsdoh.art HTTPS
1 . alpn="h3,h2,http/1.1" ech=AEH+DQA9AQAgACDdYD1C...

Reading it left to right: priority 1 with target . means "this same name". alpn="h3,h2,http/1.1" tells the client that HTTP/3 over QUIC is available, so a browser can open its first connection over h3 instead of discovering it later through an Alt-Svc header. And ech= carries the Encrypted Client Hello configuration: a public key the client uses to encrypt the sensitive parts of its TLS handshake, including the server name it is asking for. Without ECH, that name crosses the wire in plaintext in every TLS connection; the ECH guide walks through the whole mechanism.

Your dig output will not match the value printed above, and that is the point: the ECH key rotates monthly. Each rotation generates a fresh X25519 key with a new config id (visible as the byte that increments in the base64 value), and the server keeps two keys loaded - the newly advertised one, and the previous one, which stays valid for decryption only. A client holding last week's DNS answer through a cache still connects fine; a month-long grace window against a 300-second TTL leaves no gap. The rest of this section is the runbook for that: where the keys live in nginx, the script that rotates them, and the commands that prove each step worked.

A stack note first, because it decides whether you can deploy this at all: the nginx serving dnsdoh.art is compiled against BoringSSL, not OpenSSL. BoringSSL ships a native server-side ECH API and the bssl tool that generates ECH keys; upstream OpenSSL has no usable server-side ECH out of the box, which is exactly why BoringSSL is the backend here. If you run a custom OpenSSL build with ECH patches instead, everything below transfers unchanged except the key-generation command: swap bssl generate-ech for your build's equivalent.

Where the key must live in nginx

There is one architectural rule that decides whether ECH works at all, and it is easy to get wrong. Normally nginx picks the virtual host by reading the SNI name from the ClientHello. With ECH, that name is inside the encrypted payload - the whole point is that the wire only shows the public name. So decryption cannot happen in the virtual host the request is "for"; nginx does not know which vhost that is yet. The ECH keys must be loaded in the context that owns the TLS handshake for the listening socket - the default_server block - where the handshake is processed before the inner, decrypted SNI selects the real vhost:

server {
    listen 443 ssl default_server;
    listen 443 quic default_server;

    # ECH is decrypted HERE, before the inner SNI picks the real vhost.
    # First file = the advertised key; later files decrypt only (grace).
    ssl_ech_file /etc/nginx/ech.pem;
    ssl_ech_file /etc/nginx/ech-prev.pem;

    ssl_reject_handshake on;   # no certificate served for unknown names
}

Put the directives only on the named vhost instead and clients get connection failures that look random: the handshake dies in the default context before the vhost with the keys is ever selected. The same before-SNI rule applies to anything else the handshake negotiates early - this server's post-quantum TLS group list lives on the default_server for the identical reason. Each key file is a plain PEM with two blocks - the PKCS8 private key and the ECHConfigList (the same bytes the DNS record carries, base64-encoded in both places):

/etc/nginx/ech.pem (shape)
-----BEGIN PRIVATE KEY-----
(PKCS8-wrapped X25519 private key, base64)
-----END PRIVATE KEY-----
-----BEGIN ECHCONFIG-----
(the ECHConfigList - byte-identical to the DNS ech= value)
-----END ECHCONFIG-----

Note: /etc/nginx/ech-prev.pem is structurally identical - it is simply the previous month's ech.pem, moved aside by the rotation script to serve as the decryption fallback. Its position as the second ssl_ech_file directive is what makes it decrypt-only: only the first file's config is advertised to clients.

The rotation script, in full

This is the exact script that rotated the key you saw in the dig output, minus nothing. It is deliberately ordered so that every step verifies before the next commits: generate, install both keys, nginx -t, reload, prove both configs complete a real ECH handshake against the running server, and only then touch DNS. If anything fails, the script exits with the old key still loaded and the old record still published - a half-finished rotation cannot strand clients. It logs to stdout and a local file; there is no notification plumbing to leak credentials through:

/usr/local/bin/ech-rotate
#!/usr/bin/env python3
"""Monthly ECH key rotation for dnsdoh.art (nginx + BoringSSL).

Flow: generate a fresh X25519 ECH key (new config_id) -> demote the live key
to decrypt-only (dnsdoh-ech-prev.pem) -> install the new key as the advertised
config (dnsdoh-ech.pem) -> nginx -t + reload -> verify BOTH configs complete
an ECH handshake locally -> only then publish the new ECHConfigList in the
apex HTTPS record (Google Cloud DNS). Any failure aborts before the DNS step,
leaving the old key loaded and the old record published.

The previous key stays loaded until the NEXT rotation, so clients holding the
old record through DNS caches keep working for a full month (TTL is 300 s).

Usage: ech-rotate [--dry-run]   (--dry-run: generate + print, change nothing)
"""
import base64, json, os, re, shutil, subprocess, sys, tempfile, time
import urllib.request, urllib.parse

BSSL = "/root/nginx-build/boringssl/build/bssl"
LIVE = "/etc/nginx/dnsdoh-ech.pem"
PREV = "/etc/nginx/dnsdoh-ech-prev.pem"
VHOST = "/etc/nginx/sites-available/dnsdoh.art.conf"
PUBLIC_NAME = "dnsdoh.art"
SA_FILE = "/etc/letsencrypt/google_dns.json"
ZONE = "dnsdoh-art"
APEX = "dnsdoh.art."
LOG = "/var/log/ech-rotate.log"
PKCS8_X25519_PREFIX = bytes.fromhex("302e020100300506032b656e04220420")


def log(msg):
    line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
    print(line)
    try:
        with open(LOG, "a") as f:
            f.write(line + "\n")
    except OSError:
        pass


def pem_block(name, data):
    b = base64.b64encode(data).decode()
    body = "\n".join(b[i:i + 64] for i in range(0, len(b), 64))
    return f"-----BEGIN {name}-----\n{body}\n-----END {name}-----\n"


def read_echconfig_list(pem_path):
    """Return the raw ECHConfigList bytes from a rotation PEM."""
    txt = open(pem_path).read()
    m = re.search(r"-----BEGIN ECHCONFIG-----\n(.*?)-----END ECHCONFIG-----",
                  txt, re.S)
    if not m:
        raise RuntimeError(f"no ECHCONFIG block in {pem_path}")
    return base64.b64decode(m.group(1))


def config_id_of(config_list):
    # ECHConfigList: u16 total len | ECHConfig: u16 version, u16 len, u8 config_id
    return config_list[6]


def gcd_token(scope):
    sa = json.load(open(SA_FILE))
    b64 = lambda d: base64.urlsafe_b64encode(d).rstrip(b"=")
    now = int(time.time())
    claims = {"iss": sa["client_email"], "scope": scope,
              "aud": "https://oauth2.googleapis.com/token", "iat": now, "exp": now + 600}
    signing = b64(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) + b"." + b64(json.dumps(claims).encode())
    kf = tempfile.NamedTemporaryFile(delete=False)
    kf.write(sa["private_key"].encode()); kf.close()
    try:
        sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", kf.name],
                             input=signing, capture_output=True, check=True).stdout
    finally:
        os.unlink(kf.name)
    jwt = signing + b"." + b64(sig)
    data = urllib.parse.urlencode({"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
                                   "assertion": jwt.decode()}).encode()
    tok = json.load(urllib.request.urlopen(
        urllib.request.Request("https://oauth2.googleapis.com/token", data=data), timeout=15))
    return sa["project_id"], tok["access_token"]


def generate_new_pem(new_id, tmpdir):
    lst = os.path.join(tmpdir, "list.bin")
    cfg = os.path.join(tmpdir, "config.bin")
    key = os.path.join(tmpdir, "key.bin")
    subprocess.run([BSSL, "generate-ech",
                    "-out-ech-config-list", lst, "-out-ech-config", cfg,
                    "-out-private-key", key, "-public-name", PUBLIC_NAME,
                    "-config-id", str(new_id), "-max-name-length", "0"],
                   check=True, capture_output=True)
    raw = open(key, "rb").read()
    if len(raw) != 32:
        raise RuntimeError(f"unexpected private key length {len(raw)}")
    config_list = open(lst, "rb").read()
    pem = pem_block("PRIVATE KEY", PKCS8_X25519_PREFIX + raw) \
        + pem_block("ECHCONFIG", config_list)
    return pem, config_list


def install_atomic(path, content, mode=0o600):
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        f.write(content)
    os.chmod(tmp, mode)
    os.replace(tmp, path)


def ensure_prev_directive():
    """One-time: add the decrypt-only ssl_ech_file line after the live one."""
    conf = open(VHOST).read()
    if PREV in conf:
        return False
    live_line = f"ssl_ech_file {LIVE};"
    if live_line not in conf:
        raise RuntimeError(f"'{live_line}' not found in {VHOST}")
    conf = conf.replace(live_line,
                        live_line + f"\n    ssl_ech_file {PREV};", 1)
    install_atomic(VHOST, conf, mode=0o644)
    return True


def nginx_test_and_reload():
    t = subprocess.run(["nginx", "-t"], capture_output=True, text=True)
    if t.returncode != 0:
        raise RuntimeError(f"nginx -t failed: {t.stderr.strip()}")
    subprocess.run(["systemctl", "reload", "nginx"], check=True)


def verify_handshake(config_list, label):
    tmp = tempfile.NamedTemporaryFile(delete=False)
    tmp.write(config_list); tmp.close()
    try:
        for attempt in range(6):
            r = subprocess.run([BSSL, "client", "-connect", "127.0.0.1:443",
                                "-server-name", PUBLIC_NAME,
                                "-ech-config-list", tmp.name],
                               input=b"", capture_output=True, timeout=15)
            out = (r.stdout + r.stderr).decode(errors="replace")
            if "Encrypted ClientHello: yes" in out:
                log(f"handshake OK ({label} config accepted)")
                return
            time.sleep(2)
        raise RuntimeError(f"ECH handshake with {label} config not accepted:\n{out[-400:]}")
    finally:
        os.unlink(tmp.name)


def update_dns(new_list):
    new_b64 = base64.b64encode(new_list).decode()
    project, tok = gcd_token("https://www.googleapis.com/auth/ndev.clouddns.readwrite")
    base_url = f"https://dns.googleapis.com/dns/v1/projects/{project}/managedZones/{ZONE}"
    hdr = {"Authorization": "Bearer " + tok, "Content-Type": "application/json"}
    q = urllib.parse.urlencode({"name": APEX, "type": "HTTPS"})
    r = json.load(urllib.request.urlopen(
        urllib.request.Request(f"{base_url}/rrsets?{q}", headers=hdr), timeout=15))
    existing = r.get("rrsets", [])
    if not existing:
        raise RuntimeError("apex HTTPS rrset not found")
    old = existing[0]
    new_rrdatas = []
    for rd in old["rrdatas"]:
        rd2, n = re.subn(r'ech="[A-Za-z0-9+/=]*"', f'ech="{new_b64}"', rd)
        if n != 1:
            raise RuntimeError(f"could not locate ech= in rrdata: {rd}")
        new_rrdatas.append(rd2)
    if new_rrdatas == old["rrdatas"]:
        log("DNS already carries the new ECHConfigList")
        return
    change = {"deletions": [old],
              "additions": [{"name": APEX, "type": "HTTPS",
                             "ttl": old["ttl"], "rrdatas": new_rrdatas}]}
    req = urllib.request.Request(f"{base_url}/changes",
                                 data=json.dumps(change).encode(), headers=hdr)
    resp = json.load(urllib.request.urlopen(req, timeout=15))
    log(f"DNS HTTPS record updated, change status {resp['status']}")
    for attempt in range(10):
        d = subprocess.run(["dig", "+short", "@ns-cloud-e1.googledomains.com",
                            "dnsdoh.art", "HTTPS"], capture_output=True, text=True)
        if new_b64 in d.stdout:
            log("authoritative NS serves the new ech= value")
            return
        time.sleep(3)
    raise RuntimeError("new ech= value not visible on authoritative NS")


def main():
    dry = "--dry-run" in sys.argv
    cur_list = read_echconfig_list(LIVE)
    cur_id = config_id_of(cur_list)
    new_id = (cur_id + 1) % 256
    log(f"rotation start: config_id {cur_id} -> {new_id}{' (dry-run)' if dry else ''}")

    with tempfile.TemporaryDirectory() as tmpdir:
        new_pem, new_list = generate_new_pem(new_id, tmpdir)

    if dry:
        print(f"would advertise config_id={config_id_of(new_list)} "
              f"ech=\"{base64.b64encode(new_list).decode()}\"")
        return 0

    old_live = open(LIVE).read()
    install_atomic(PREV, old_live)
    install_atomic(LIVE, new_pem)
    added_directive = ensure_prev_directive()
    try:
        nginx_test_and_reload()
    except Exception:
        install_atomic(LIVE, old_live)   # roll back; PREV holding a copy is harmless
        subprocess.run(["nginx", "-t"], capture_output=True)
        raise

    verify_handshake(new_list, "new")
    verify_handshake(cur_list, "previous")
    update_dns(new_list)
    if added_directive:
        log(f"added decrypt-only ssl_ech_file line to {VHOST}")
    log(f"rotation complete: advertising config_id {new_id}, "
        f"config_id {cur_id} kept decrypt-only until next rotation")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:  # noqa: BLE001
        log(f"ERROR: {exc}")
        sys.exit(1)

To adapt the script, know where the provider boundary runs. The constants at the top are your file paths - key locations, nginx vhost, the bssl binary. Everything from key generation through the nginx reload and handshake checks is provider-independent and runs as-is. Exactly one function is not: update_dns(new_list) as published speaks strictly to Google Cloud DNS (a robust API, but not what most readers run), authenticating with a service-account JWT signed locally, so the only credential on disk is the same one certificate automation typically already holds.

On Cloudflare, Route53, a self-hosted BIND or Knot, or any other provider, replace that one function with your own that fulfils the same three-step contract: fetch the current apex HTTPS record, replace the base64 value inside ech="..." with the new ECHConfigList the script just generated (leaving alpn and everything else in the record untouched), and push the change. Keep the verification step at the end - poll your authoritative nameserver until the new value is actually served, because the rotation is not done when the API call returns; it is done when clients can see the new key. Then schedule the script. Two small systemd units are enough:

# /etc/systemd/system/ech-rotate.service
[Unit]
Description=Rotate the ECH key (advertise new, keep previous decrypt-only)
After=network-online.target nginx.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ech-rotate

# /etc/systemd/system/ech-rotate.timer
[Unit]
Description=Monthly ECH key rotation

[Timer]
OnCalendar=monthly
RandomizedDelaySec=4h
Persistent=true

[Install]
WantedBy=timers.target

Proving a rotation worked

The script verifies itself, but trust comes from checking from the outside, the way a client would. Fetch the ECH config from public DNS, hand it to bssl client, and look for the acceptance line; then repeat with the previous config to confirm the grace path.

Note on bssl: this is not a standard system utility, so copying the command verbatim will fail with "command not found". It is the BoringSSL command-line tool, compiled alongside the library during the build (the bssl ninja target). Replace /path/to/boringssl below with the absolute path of your own BoringSSL build directory:

# pull the ECHConfigList a real client would use, straight from public DNS
$ dig +short dnsdoh.art HTTPS | grep -o 'ech=[^ ]*' | cut -d= -f2- | base64 -d > /tmp/ech.list

# handshake with it against the live server
$ /path/to/boringssl/build/tool/bssl client -connect 194.180.189.33:443 -server-name dnsdoh.art -ech-config-list /tmp/ech.list
Encrypted ClientHello: yes

And one honest caveat to close the layer: dnsdoh.art sits alone on its IP address, so hiding the server name in the handshake buys limited privacy here - the IP already narrows it to one site. ECH earns its keep on shared infrastructure, where one IP fronts many names. It runs here regardless: the operational lessons - key placement, rotation, the DNS interplay - are the same ones that matter at scale, and running it is how you learn them.

Layer 4 - Discovery: DDR, the record that is not in the zone

One set of records in this tour will not show up in any zone transfer, because it is not served by the authoritative zone at all. Ask the resolver itself - the special name _dns.resolver.arpa is answered directly by the DNS server running at 194.180.189.33:

$ dig +short _dns.resolver.arpa SVCB @194.180.189.33
1 dnsdoh.art. alpn="h2,h3" port=443 ipv4hint=194.180.189.33 key7="/dns-query{?dns}"
1 dnsdoh.art. alpn="dot" port=853 ipv4hint=194.180.189.33
1 dnsdoh.art. alpn="doq" port=853 ipv4hint=194.180.189.33

This is Discovery of Designated Resolvers (DDR, RFC 9462). A device that was handed only the plain IP address - by a router over DHCP, say - sends this one query and learns every encrypted endpoint: DoH over HTTP/2 and HTTP/3 at /dns-query, DoT and DoQ on port 853. The ipv4hint on every line is a small optimization with an outsized effect at exactly this moment: it hands the client the target address inside the SVCB answer, so it can open the encrypted handshake immediately instead of first resolving the A record for dnsdoh.art - one round trip saved during bootstrap, the same shape Cloudflare uses in its DDR records. Hints are non-authoritative (RFC 9460): clients still resolve the name and prefer those answers, so the hint cannot go stale in a harmful way. Windows 11 and Apple platforms do this automatically and then verify the designation: the TLS certificate of the designated server must cover both the name and the original resolver IP, or the upgrade is rejected. The discovery query itself travels in plain DNS, so that certificate check is what makes the answer trustworthy.

The certificate serving dnsdoh.art carries IP:194.180.189.33 alongside the names precisely for this check. How devices discover encrypted DNS explains the mechanism; the verified-DDR runbook documents how this deployment was built and tested end to end, including watching Windows 11 upgrade itself.

Layer 5 - Mail lockdown: records for the email we never send

dnsdoh.art sends no email. Left unsaid, that fact is an opening: anyone can put @dnsdoh.art in a From: header, and a receiving server has no way to know the domain never sends. Three records close the gap by stating the policy where every mail server will look for it:

# null MX (RFC 7505): this domain receives no mail
$ dig +short dnsdoh.art MX
0 .

# SPF with no permitted senders: any server claiming to send for us is lying
$ dig +short dnsdoh.art TXT | grep spf
"v=spf1 -all"

# DMARC: and receivers should reject such mail outright
$ dig +short _dmarc.dnsdoh.art TXT
"v=DMARC1; p=reject;"

The division of labour: the null MX says the domain receives nothing, v=spf1 -all declares an empty list of permitted senders, and the DMARC p=reject policy tells receivers to act on the failure - reject, not quarantine. Because the zone is DNSSEC-signed, even these policy records are tamper-evident. Every no-mail domain should carry all three; they cost three records and remove an entire impersonation channel.

Layer 6 - Issuance control: CAA

The last layer governs who may create certificates for the name. Certificate authorities are required to check CAA before issuing, and this zone allows exactly one:

$ dig +short dnsdoh.art CAA
0 issue "letsencrypt.org"

Every other CA must refuse issuance for dnsdoh.art. Combined with the layers above, a would-be impersonator faces a stack of independent refusals: they cannot forge zone answers (DNSSEC), cannot present a different key to DANE-aware clients (TLSA), and cannot obtain a certificate from any CA except the one whose issuance this zone controls through its own account. And because the CAA record sits in a signed zone, stripping or altering it in transit fails validation too.

Read any zone this way

The same six questions apply to any domain - yours, your employer's, a service you are evaluating. The whole audit is six dig commands, no access required:

$ d=example.com
$ dig +short $d DS             # 1. signed? empty = no DNSSEC chain
$ dig +short _443._tcp.$d TLSA  # 2. key pinned?
$ dig +short $d HTTPS           # 3. h3 advertised? ECH key present?
$ dig +short _dns.resolver.arpa SVCB @1.1.1.1  # 4. DDR (resolvers only)
$ dig +short $d MX; dig +short $d TXT | grep spf1; dig +short _dmarc.$d TXT  # 5. mail policy
$ dig +short $d CAA             # 6. issuance restricted?

Most domains fail most of these, and each empty answer is a concrete, fixable finding. A zone with no DS is unsigned, so nothing else in it can be trusted further than the transport that delivered it. And if your zone delegates subdomains, check the other direction too: the parent must prove DS absence for unsigned delegations, or validating resolvers drop them - The Missing NSEC is our field report of exactly that failure. A no-mail domain without the three mail records can be impersonated in email freely. A missing CAA record means every CA on earth may issue for the name.

None of these records is exotic. Everything in this guide was deployed with a registrar that supports DS records, a DNS host with proper record-type support, and standard tooling. The zone is the cheapest place to harden a service: records are free, public, and verifiable by anyone - which is why this guide could be written against the live production zone rather than an example.

The zone is half the story

These records describe the service; the resolver behind them does the daily work. How it is built, filtered and defended is documented in the same spirit: the real configuration, not a diagram.

How this service is run

Go deeper on any layer: DNSSEC, Encrypted Client Hello, DDR discovery, DNS record types, or build a validating resolver of your own.

Ozy-666 Author · Creator of dnsdoh.art

Ozy-666 builds and operates dnsdoh.art, an encrypted DNS resolver serving DoH, DoH3, DoT and DoQ. He maintains its Edge-optimised AdGuardHome and dnscrypt-proxy forks and the nginx+BoringSSL TLS stack described in this guide. Every record shown here is live in the production zone; every dig command was run against it while writing.