Skip to content

Repository files navigation

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_verifiedTrue 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

About

Reference implementation of the ApertoID agent identity protocol (IETF draft-ferro-dnsop-apertoid and -httpbis-apertoid-sig)

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages