Reference implementation of ApertoID, an open protocol for AI agent identity built on DNS. ApertoID lets a domain owner declare which AI agents act on its behalf, publish Ed25519 public keys for them in DNS, and lets agents cryptographically sign each HTTP request so a service can verify the request really came from the declared agent. It follows the same layered pattern as SPF/DKIM/DMARC for email.
The protocol is specified in two IETF Internet-Drafts:
- DNS records — draft-ferro-dnsop-apertoid-02 (Policy Record and Agent Declaration Record).
- HTTP request signing — draft-ferro-httpbis-apertoid-sig-02
(the
ApertoID-Signatureheader).
This package is the reference implementation of the core operations in those
drafts, written strictly from the spec text (the same text lives in spec/).
DNS record parsing and validation (apertoid.parser)
parse_record(raw)turns a single TXT record string into a validatedParsedRecord, checking every tag value against the draft's Section 5.1 ABNF and enforcing the cross-tag rules from Sections 7.3 and 8 (for exampleurl/includemutual exclusion,krequirespkandexp, duplicate known tags are apermerror, revocation records are exempt from needing an endpoint). It never raises; malformed input is reported as diagnostics, which mirrors the spec'spermerrorposture.validate_selector(selector)checks an agent selector against the Section 7.1 DNS-label rule.
HTTP request signing and verification (apertoid.sig)
construct_signing_input(...)builds the deterministic, byte-exact signing input of Section 3.1 (seven LF-terminated components).sign(...)produces a raw 64-byte Ed25519 signature, unpadded Base64 (86 characters).build_header(...)assembles theApertoID-Signatureheader value.parse_header(...)parses that header back into its tags.verify(...)performs the local verification of Section 4: header parse, a two-sided timestamp window, nonce replay check, and the Ed25519 signature check. Per the draft's Section 4 step 9a, a nonce is recorded in the caller's replay cache only after the signature verifies, so a request with a bad signature cannot burn a nonce or flood the cache. The validitywindowmust be within the Section 5 range of 60 to 600 seconds; a value outside that range raisesValueError. An optionalmax_body_sizerejects an over-large body (resultbody_too_large) before it is hashed.
End-to-end verification (apertoid.verify)
verify_apertoid(claimed_domain, selector, agent_url, resolver, ...)implements the DNS verification procedure of Section 11.2 in full: policy and agent-record lookup, Section 6.1 multi-record selection, revocation (checked first),include=delegation with the Section 8 DoS limits (at most two delegation hops, at most 10 total DNS queries, cycle detection) and a revocation re-check on every delegated record,expexpiry against wall-clock time, and Section 11.4 URL matching. It returns aVerificationResultcarrying the outcome, the algorithm step that produced it, the domain's enforcement policyp=, and the resolved public key.verify_request(header_value, resolver, method, target, body, agent_url, ...)is the signature-to-DNS bridge (-sigSection 4): it resolves and validates the agent viaverify_apertoid, and only if that passes verifies the request signature viasig.verifywith the DNS-published key. DNS is resolved through an injectableResolver, so verification runs offline in tests and against live DNS in production viaDnsPythonResolver(optional, needsdnspython). A signed request to aurl=-only agent record (authorized by URL, no key published — a legal early deployment stage) returnspasswithsignature_verified=False, so a caller can always tell a URL-authorized result apart from a cryptographically-verified one.
Conformance harness (tests/)
- The test suite parses the record and signature examples printed in the two drafts and checks them byte-for-byte: every example DNS record parses as the spec dictates, and the drafts' own example signature cryptographically verifies against the published public key.
- Run in CI across Python 3.10 through 3.14.
The spec work also produced two catalogues of ambiguities and defects found
while implementing from the text: FINDINGS.md (DNS draft) and
FINDINGS-sig.md (signing draft).
Both layers and the bridge between them are implemented. verify_apertoid
covers the full Section 11.2 DNS verification algorithm — record lookup and
selection, revocation, include= delegation with its Section 8 limits, exp
expiry, and Section 11.4 URL matching — and verify_request ties that to the
signature check of -sig Section 4, resolving the agent's key from DNS instead
of taking it as an argument. DNS access goes through an injectable Resolver;
DnsPythonResolver provides live lookups (optional, needs dnspython), while
StaticResolver drives the tests offline.
What is genuinely not built:
prev=key-rotation continuity verification (Section 10.2). Following a key rotation requires a cache of previously seen keys to check theprevsignature against; that historical key store is deferred to future work. Key rotation still works at the DNS-authority level (a new key published by the zone owner is accepted); only the extra continuity proof is unverified.- Live DNS is optional, not the default. The core logic depends only on the
Resolverprotocol. Production callers pass aDnsPythonResolver; nothing in the library performs network I/O on its own.
The implementation was written strictly from the spec, and the ambiguities and
gaps that surfaced while building the verifier are catalogued as findings P1–P8
in FINDINGS.md (with the corresponding S16 in
FINDINGS-sig.md) and folded into subsequent draft revisions.
pip install apertoidRequires Python >= 3.9 and depends on cryptography (for Ed25519). Live DNS
resolution (DnsPythonResolver) needs the optional dnspython package:
pip install "apertoid[dns]"from apertoid import parse_record, sig
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# Parse and validate a DNS Agent Declaration Record.
rec = parse_record(
"v=APERTOID1; url=https://agent.example.com/mcp; "
"k=ed25519; pk=2TmyMjizLUEeS0F9GJvGedF4syZFYvrWl+oFHv56VSY; "
"type=ai; exp=1759276800"
)
print(rec.record_type) # RecordType.AGENT
print(rec.is_valid) # True
print(rec.get("pk")) # the 43-char raw Ed25519 key
for d in rec.diagnostics:
print(d) # [severity:code] message
# Sign an HTTP request and verify it.
sk = Ed25519PrivateKey.generate()
d, s, t, n = "example.com", "leadhunter", "1711100000", "a1b2c3d4e5f6"
method, target, body = "POST", "/mcp/tools/search", b'{"query": "leads"}'
signature = sig.sign(sk, d, s, t, n, method, target, body)
header = sig.build_header(d, s, t, n, signature) # ApertoID-Signature value
result = sig.verify(
header, sk.public_key(), method, target, body,
current_time=int(t), seen_nonces=set(),
)
print(result.result) # "pass"sig.verify(...) returns a VerifyResult whose .result is one of pass,
malformed, timestamp_invalid, nonce_reused, sig_invalid, or (only when
max_body_size is set) body_too_large. It takes the public key as an argument.
To resolve that key from DNS and verify the whole request in one call, use
verify_request below.
from apertoid import verify_request, StaticResolver, sig, Outcome
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# The agent's signing key, and its raw public key encoded for the DNS record
# (43-char unpadded Base64).
sk = Ed25519PrivateKey.generate()
pk_raw = sk.public_key().public_bytes_raw()
pk_b64 = sig.b64_unpadded(pk_raw)
d, s, t, n = "example.com", "leadhunter", "1711100000", "a1b2c3d4e5f6"
method, target, body = "POST", "/mcp/tools/search", b'{"query": "leads"}'
agent_url = "https://agent.example.com/mcp" # canonical URL the request hit
# The domain's DNS records. In production this is a DnsPythonResolver; here a
# StaticResolver stands in so the example is self-contained.
resolver = StaticResolver({
"_apertoid.example.com": "v=APERTOID1; p=reject",
"leadhunter._apertoid.example.com": (
f"v=APERTOID1; url={agent_url}; k=ed25519; pk={pk_b64}; "
f"type=ai; exp=4102444800"
),
})
# The agent signs the request and sends the header.
signature = sig.sign(sk, d, s, t, n, method, target, body)
header = sig.build_header(d, s, t, n, signature)
# The service verifies it end to end: DNS lookup + key resolution + signature.
result = verify_request(
header, resolver, method, target, body, agent_url,
current_time=int(t), seen_nonces=set(),
)
print(result.outcome) # Outcome.PASS
print(result.step) # "pass"
print(result.policy) # Policy.REJECT (what to do if it had failed)
print(result.pk) # the resolved 43-char key
print(result.signature_verified) # True (the Ed25519 signature checked out)verify_request(...) and verify_apertoid(...) return a VerificationResult
with:
.outcome— anOutcome:pass,none,revoked,expired,url_mismatch,key_mismatch,permerror,temperror, or the signature-layermalformed,timestamp_invalid,nonce_reused,sig_invalid..step— the algorithm step that produced the result (e.g."11.2#7","sig#9","pass"), for logging and debugging..policy— the domain'sp=, so a caller learns both that a request failed and whether the domain says to reject it..pk— the resolved public key (orNonefor aurl=-only record)..signature_verified—Trueonly when an Ed25519 request signature was cryptographically checked and passed;Falsefor a URL-onlypass, for anyverify_apertoid(DNS-only) result, and for any failure.
If the DNS side fails, verify_request returns that result without checking the
signature.
python3 -m venv .venv
.venv/bin/pip install -e ".[test]"
.venv/bin/python -m pytest -qMIT. Source: https://github.com/ApertoID/apertoid