Skip to main content

apertoid

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:

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/).

What this package provides

DNS record parsing and validation (apertoid.parser)

  • parse_record(raw) turns a single TXT record string into a validated ParsedRecord, 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 example url/include mutual exclusion, k requires pk and exp, duplicate known tags are a permerror, revocation records are exempt from needing an endpoint). It never raises; malformed input is reported as diagnostics, which mirrors the spec's permerror posture.
  • 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 the ApertoID-Signature header 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 validity window must be within the Section 5 range of 60 to 600 seconds; a value outside that range raises ValueError. An optional max_body_size rejects an over-large body (result body_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, exp expiry against wall-clock time, and Section 11.4 URL matching. It returns a VerificationResult carrying the outcome, the algorithm step that produced it, the domain's enforcement policy p=, and the resolved public key.
  • verify_request(header_value, resolver, method, target, body, agent_url, ...) is the signature-to-DNS bridge (-sig Section 4): it resolves and validates the agent via verify_apertoid, and only if that passes verifies the request signature via sig.verify with the DNS-published key. DNS is resolved through an injectable Resolver, so verification runs offline in tests and against live DNS in production via DnsPythonResolver (optional, needs dnspython). A signed request to a url=-only agent record (authorized by URL, no key published — a legal early deployment stage) returns pass with signature_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).

Scope

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 the prev signature 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 Resolver protocol. Production callers pass a DnsPythonResolver; 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.

Install

pip install apertoid

Requires Python >= 3.9 and depends on cryptography (for Ed25519). Live DNS resolution (DnsPythonResolver) needs the optional dnspython package:

pip install "apertoid[dns]"

Usage

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.

End-to-end: verify a signed request against DNS

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 — an Outcome: pass, none, revoked, expired, url_mismatch, key_mismatch, permerror, temperror, or the signature-layer malformed, 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's p=, so a caller learns both that a request failed and whether the domain says to reject it.
  • .pk — the resolved public key (or None for a url=-only record).
  • .signature_verified — True only when an Ed25519 request signature was cryptographically checked and passed; False for a URL-only pass, for any verify_apertoid (DNS-only) result, and for any failure.

If the DNS side fails, verify_request returns that result without checking the signature.

Development

python3 -m venv .venv
.venv/bin/pip install -e ".[test]"
.venv/bin/python -m pytest -q

License

MIT. Source: https://github.com/ApertoID/apertoid

Metadata

Release files for apertoid 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for apertoid 0.2.0
File Size Uploaded
apertoid-0.2.0.tar.gz 58.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for apertoid 0.2.0
File Interpreter ABI Platform
apertoid-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 90.8 kB

Release files / apertoid-0.2.0.tar.gz

Download URL apertoid-0.2.0.tar.gz
Size 58.5 kB
Tags Source
SHA-256 checksum
How to use checksums
21de90106518ad6eee6f5fc79bfab1c46321d0c41f8297e72d0de513e09d3fbe
BLAKE2b-256 checksum
How to use checksums
f36dd47de137014d71758dc1fdd3aab6ad9df35fad7c548d53433c9133c901a7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / apertoid-0.2.0-py3-none-any.whl

Download URL apertoid-0.2.0-py3-none-any.whl
Size 32.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f436393d2f04fd34fad6cd77360da81368b0cdb97e4ab27775eadab0b366b9b6
BLAKE2b-256 checksum
How to use checksums
25205403596b5e75a6d1464829819c585dcc845813d44f78618f31309735c605
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page