DCV — Proving an Agent Belongs to a Domain Without a Central Authority
A stateless challenge/response primitive over TXT records that lets a NAT'd agent prove zone control without registering with anyone.

Introduction
There's a recurring shape of problem in the agent-discovery work I've been doing: an agent runs somewhere — behind NAT, inside a container, on a coworker's laptop — and it wants to assert "I'm part of example.com." The receiver has no a priori reason to believe it. There's no central registry to check. The agent has no public IP and no signed certificate the receiver already trusts. What it does have is the ability to talk to the operator of example.com's DNS, because that's its employer.
The classic shape of this problem is domain control validation — how Let's Encrypt proves to itself that the person asking for a cert for example.com actually controls example.com. ACME's HTTP-01 and DNS-01 challenges are the canonical pattern: a CA hands you a random token, you place it somewhere only the domain operator could (a /.well-known/ path or a _acme-challenge TXT record), the CA looks for it, and the existence of the token in the right place is the proof.
Agents need the same primitive. So I built DCV — Domain Control Validation — into the dns-aid-core reference implementation. PR #111 merged today; the wire format is grounded in draft-ietf-dnsop-domain-verification-techniques-12 with an extension field from draft-mozleywilliams-dnsop-dnsaid-02.
The Two Use Cases
Anonymous / NAT agent asserts org affiliation. Org A wants to delegate a task to an agent claiming to belong to Org B. Org A issues a DCV challenge. Org B's agent, using whatever credentials it already has for its employer's DNS, places the challenge token at _agents-challenge.orgb.test. Org A verifies. The chain of trust is: if you can write a record to orgb.test, you have a working relationship with the operator of orgb.test, which is the best stand-in for "you're part of Org B" the network layer can offer.
Registry / directory anti-impersonation. A directory wants to flag agents as "org-verified" before listing them. The directory becomes the challenger; the agent's employer publishes the challenge response in their own zone. The directory now has a substrate-level proof that the agent's claimed affiliation is real, and a revocation path if it stops being real.
Both flows use the same primitive. Roles split cleanly: the challenger needs no DNS write credentials (they just verify), the claimant needs no challenger credentials (they just place the record in their own zone).
Wire Format
A single TXT record at _agents-challenge.{domain} carries the challenge:
_agents-challenge.orgb.test. 300 IN TXT "token=ay4cpz5lbu5osnp3p5xqcuxnj4vt5h22 domain=orgb.test bnd-req=svc:[email protected] expiry=2026-05-08T21:00:00Z"Four fields, space-separated key=value:
token— 32-char lowercase base32, 160 bits of entropy. Generated by the challenger, never reused.domain— the zone the token was issued for. Prevents cross-domain replay: an attacker who steals the token can't reuse it against a different domain without rewriting this field, which would require write access to the target zone.bnd-req— optional binding to a specific agent + issuer pair (svc:<agent>@<issuer>). Lets the challenger say "this token only validates for this agent claiming affiliation with this org," which kills cross-vendor token reuse — the DCV hazard H2 in the techniques draft.expiry— RFC 3339 UTC. Fail-closed: missing or unparseableexpiryis treated as an invalid record. There's no magic "never" value (an earlier draft had one — it was an exploit vector).
A Round Trip Against the Testbed
The repo ships a two-org BIND9 testbed (orga.test, orgb.test). Org A challenges Org B; Org B places the record; Org A verifies. Captured live against the two BIND9 containers:
$ docker exec agent-a dns-aid dcv issue orgb.test --agent assistant --issuer orga.test --json
{
"token": "zszwntk6mt3ao7muh3xizixuqtldcpus",
"domain": "orgb.test",
"fqdn": "_agents-challenge.orgb.test",
"txt_value": "token=zszwntk6mt3ao7muh3xizixuqtldcpus domain=orgb.test bnd-req=svc:[email protected] expiry=2026-05-12T18:41:03Z",
"expiry": "2026-05-12T18:41:03.851285Z",
"bnd_req": "svc:[email protected]"
}
$ docker exec agent-b dns-aid dcv place orgb.test zszwntk6mt3ao7muh3xizixuqtldcpus --bnd-req svc:[email protected]
✓ Challenge placed at _agents-challenge.orgb.test
$ docker exec agent-a dig @172.28.0.11 _agents-challenge.orgb.test TXT +short
"token=zszwntk6mt3ao7muh3xizixuqtldcpus domain=orgb.test bnd-req=svc:[email protected] expiry=2026-05-12T18:41:03Z"
$ docker exec agent-a dns-aid dcv verify orgb.test zszwntk6mt3ao7muh3xizixuqtldcpus --nameserver 172.28.0.11 --bnd-req svc:[email protected]
✓ DCV verified for _agents-challenge.orgb.test
$ docker exec agent-b dns-aid dcv revoke orgb.test zszwntk6mt3ao7muh3xizixuqtldcpus
✓ Challenge record removed from orgb.testThat's the whole flow. Org A issued, Org B placed (using its own DDNS credentials for orgb.test via the ddns backend), the record appeared in BIND, Org A verified, Org B revoked. No central authority, no API token issuance, no PKI. The verifier needs only DNS resolution; the claimant needs only their own zone's update credentials.
What the Review Pass Surfaced
PR #108 went through three review passes before being merged as #111. Most of that churn was security: the first draft of verify() had five exploitable paths against a real BIND9 grid:
- Missing
expiry=field passed verification. A record with no expiry was treated as valid forever. Fix: fail closed when the field is absent. - Malformed
expiry=NOT-A-DATEpassed verification. A barepassswallowed the parse error and fell through. Fix: malformed expiry is invalid. expiry=nevermagic string passed verification. Undocumented bypass. Removed.- Bare-string token (no
token=prefix) passed verification. A TXT record with the bare token value would match. Fix: the wire format requires explicitkey=for every field; first-occurrence wins on duplicates. - Bad nameserver IP raised an exception instead of returning a structured
verified=False. SSRF-adjacent surface. Fix:ipaddress.ip_address()validates before passing to the resolver; failures return aDCVVerifyResult(verified=False)with an error field, never raise.
There were others — Cloudflare's TXT-quoting behavior breaks the parser on roundtrip if you don't strip outer quotes ("..."); the bnd-req field wasn't actually enforced in the first cut even when supplied; revoke() would delete any matching TXT record at the challenge name rather than verifying the token first. All confirmed against live infrastructure during review, all fixed before merge.
The pattern that came out of this: every parser for DNS data needs to be fail-closed by default and tested adversarially. Missing fields, malformed fields, duplicate fields, empty fields, encoding fields — each one is an injection vector if you assume the wire is well-formed.
Tradeoffs Worth Naming
DCV is one-shot today. A successful verify() proves zone control at the moment of verification, not in perpetuity. For directories that want to list agents as org-verified for days at a time, that's not enough — they need a re-verification schedule and a way to expire stale listings. That's follow-up work; the primitive is the foundation, not the policy.
The tokens are random, not signed. Anyone with access to the challenge channel can replay a token if it's still in DNS. The expiry window and the immediate revoke() after verify() close most of the window, but a future revision with HMAC-bound tokens (challenger holds a signing key, token = HMAC(key, domain ‖ expiry ‖ nonce)) would let verify() detect tampering and remove the per-issuance state requirement entirely. Igor flagged this at merge as a follow-up; it's a real improvement and on the list.
Backend roundtrip-testing is harder than it looks. Cloudflare's TXT-quoting bug only surfaced because Igor ran live integration tests against a real Cloudflare zone during review. The mocked unit tests passed because the mocks didn't model the wrapping behavior. The follow-up here is a contract test matrix that exercises Cloudflare / Route53 / Infoblox / DDNS roundtrips for every wire format the protocol cares about — gated behind @pytest.mark.live so it doesn't burn CI minutes, but accessible enough that a contributor can run it before opening a PR.
Why This Matters for the Agent-Discovery Story
DNS-AID puts agent discovery on DNS instead of a central registry. That only works if a receiver can answer one question on its own: does the agent on the other end of an SVCB record actually belong to the domain it claims? Without an answer, discovery is a phone book — you can look up the agent, but the caller-ID is unsigned, and a registry creeps back in as the thing you ask to vouch for it.
DCV is how the receiver answers that question without asking anyone. If the agent's employer can place a token in example.com's zone, that's proof rooted in the same delegation chain that already decides who controls example.com — not a vendor's assertion, not a database row. Get this wrong and the whole no-registry argument collapses, which is why the verifier got picked apart five times before it merged.
The next thing to build on top is signaling: letting clients tell the resolver what kind of agent they're looking for so warm caches can short-circuit cold searches. That's the EDNS(0) work I'm sketching now, and it'll get its own post when there's something to show.
Spot a typo or want to suggest a change? Edit lands as a PR against the public mirror.