Metadata-Version: 2.5
Name: dnsid
Version: 0.28.0
Summary: Python SDK for the DNSid Protocol — agent identity management and verification
Project-URL: Documentation, https://docs.dnsid.ai/
Project-URL: Repository, https://github.com/dnsid-ai/dnsid-py
Project-URL: Issues, https://github.com/dnsid-ai/dnsid-py/issues
Project-URL: Changelog, https://github.com/dnsid-ai/dnsid-py/blob/main/CHANGELOG.md
Author: Known Systems AI, Inc.
License-Expression: Apache-2.0
License-File: LICENSE.txt
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: cryptography>=42.0
Requires-Dist: dnspython>=2.8
Requires-Dist: http-sf<2,>=1.3.0
Requires-Dist: httpx>=0.27
Requires-Dist: idna>=3.6
Provides-Extra: aws
Requires-Dist: boto3>=1.34; extra == 'aws'
Provides-Extra: dev
Requires-Dist: griffe>=1.5; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# dnsid-py

Python SDK for the [DNSid Protocol](https://datatracker.ietf.org/doc/draft-ihsanullah-dnsid/) — agent identity management and verification.

DNSid lets agents publish a cryptographically signed identity record in DNS and verify the identities of other agents before exchanging data.

## Installation

```bash
pip install dnsid
```

For AWS KMS-backed signing keys, install the `aws` extra:

```bash
pip install "dnsid[aws]"
```

If the release is not yet available on PyPI, install directly from GitHub.
Replace `main` with a [release tag](https://github.com/dnsid-ai/dnsid-py/releases)
to pin a version; the `[aws]` extra works the same way:

```bash
pip install "dnsid @ git+https://github.com/dnsid-ai/dnsid-py@main"
```

Requires Python 3.11+. CI-tested against Python 3.11, 3.12, and 3.13. Dependencies: `idna`, `httpx`, `cryptography`, `dnspython`, `http-sf`. The package ships a `py.typed` marker (PEP 561), so mypy and other type checkers see its full strict-mode annotations.

See **[COMPATIBILITY.md](https://github.com/dnsid-ai/dnsid-py/blob/main/COMPATIBILITY.md)** for the full runtime and dependency compatibility matrix,
including tested Python versions, dependency version ranges, optional extras, and platform support.

## First verification

For this local signing identity, set `DNSID_LOG_REF` to its persisted,
registry-provided `c2sp-tlog:public:` reference on a supported DNSid-managed
log. A verification-only manager needs no local identity or reference (see the
[Quickstart](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/quickstart.md)).
The `.example` domains below are placeholders; networked flows require published
DNSid identities. Local CLI examples use the `.test` zone.

```python
import os
from pathlib import Path

from dnsid import DnsidConfig, IdentityConfig, IdentityManager, IdentityManagerDependencies, LocalKeyProvider
from dnsid.c2sp_tlog import create_dnsid_managed_verification_registry

manager = IdentityManager(
    DnsidConfig(identity=IdentityConfig(
        domain="billing-agent.example", governance_id="billing-agent.example",
        log_ref=os.environ["DNSID_LOG_REF"],  # persisted c2sp-tlog identity-instance reference
        status_url="https://billing-agent.example/status",
    )),
    LocalKeyProvider.load(Path("~/.dnsid/keys.json").expanduser(), create_if_missing=True),
    # Explicitly trust the SDK's pinned DNSid-managed public log roots.
    deps=IdentityManagerDependencies(log_registry=create_dnsid_managed_verification_registry()),
)

# For a counterparty using a supported DNSid-managed public log:
vd = manager.verify_domain("payments-agent.example")
print(vd.domain, vd.cached_state())
```

Draft-01 verification fails closed if the domain's `lr` uses a different log
method or trust root; configure a matching reader (see the
[transparency-log reference](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/transparency-log.md)). The
[Quickstart](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/quickstart.md) shows verification setup. The default
`auto` DNSSEC policy works with the built-in resolver; `validated` and
`required` need a DNSSEC-aware resolver injected through
`IdentityManagerDependencies.dns_resolver`.

## What's in the SDK

- **Core protocol** — `IdentityManager` drives every protocol operation: domain verification, TXT-record creation and publication, key rotation, and lifecycle-event signing.
- **Application profiles** (not part of the core protocol): JWT/JWS ([JOSE](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/profile-jose.md)), [RFC 9421 HTTP Message Signatures](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/profile-http-signatures.md), [Web Bot Auth](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/profile-web-bot-auth.md), and [OIDC](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/profile-oidc.md).
- **Key management** — private keys stay behind the `KeyProvider` interface: file-backed with `LocalKeyProvider`, or externalized entirely with `AwsKmsKeyProvider` or your own KMS/HSM-backed implementation.
- **Transparency log** — a ready-made C2SP tile-log `LogReader` (`dnsid.c2sp_tlog`).
- **Registry client** — operator-side registration and publication workflows.

**Import surface.** Everything is importable from the package root (`from dnsid import IdentityManager, JoseProfile`), mirroring the TypeScript SDK's umbrella package. The profile classes are also importable from their home submodules (`dnsid.jose`, `dnsid.http_signatures`) — both paths refer to the same classes. The transparency-log integration is the exception: import it from `dnsid.c2sp_tlog`.

**Synchronous by design.** Verification and most public operations are synchronous. Async support includes `RegistryClient.async_wait_for_status()` (which polls synchronously), signed `httpx.AsyncClient` transport plumbing, and `async_retry_transient`. Call blocking SDK operations from async code via `asyncio.to_thread` or an executor.

## Documentation

- **[docs.dnsid.ai](https://docs.dnsid.ai/)** — hosted guides, concepts, and the product CLI documentation
- **[Quickstart Guide](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/quickstart.md)** — installation, prerequisites, and core workflows (resolve, sign, verify)
- **[API Reference](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/overview.md)** — generated from docstrings ([also on docs.dnsid.ai](https://docs.dnsid.ai/reference/py/overview/)); regenerate with `python scripts/gen_docs.py`
- **[Security](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/security.md)** — threat model, key-rotation/JWT-overlap guidance, and production operations notes

## Quick start

### Authenticating with JWT

```python
from dnsid import JWTOptions
from dnsid.jose import JoseProfile

jose = JoseProfile.from_identity_manager(manager)

# Create a signed JWT to send to a counterparty
token = jose.create_jwt(JWTOptions(audience="payments-agent.example"))

# Verify a JWT received from a counterparty
vd = jose.verify_jwt(token)
```

### Minting DNSid OIDC tokens from server-side code

Use `OIDCProfile` when a server-side agent needs a DNSid OIDC access token for
an outbound call, for example an AgentCore-hosted Org A agent calling an Org B
AgentCore Gateway/tool. Keep the signing key on the server; do not mint DNSid
OIDC tokens in browser, mobile, or other client-side code.

```python
import httpx
import os
from pathlib import Path

from dnsid import (
    DnsidConfig,
    IdentityConfig,
    IdentityManager,
    LocalKeyProvider,
    OIDCConfig,
    OIDCProfile,
    OIDCTokenExchangeOptions,
)

key_provider = LocalKeyProvider.load(Path("/var/lib/dnsid/agent-signing-key.jwk"))
manager = IdentityManager(
    DnsidConfig(
        identity=IdentityConfig(
            domain="agent-a.example",
            governance_id="agent-a.example",
            log_ref=os.environ["DNSID_LOG_REF"],  # this agent's persisted c2sp-tlog reference
            status_url="https://agent-a.example/status",
        ),
    ),
    key_provider,
)

with httpx.Client(timeout=5.0) as http:
    oidc = OIDCProfile.from_identity_manager(
        manager,
        OIDCConfig(
            default_issuer="https://oidc.dnsid.ai",
            http_client=http,
            timeout=5.0,
        ),
    )
    token_response = oidc.mint_oidc_token(
        OIDCTokenExchangeOptions(
            audience="https://gateway.example",
            scope=["openid", "dnsid"],
        )
    )

headers = {"Authorization": f"Bearer {token_response.access_token}"}
```

The issuer is configurable. For local/proxy-style deployments that mirror the
CLI behavior, pass `server_url="http://127.0.0.1:3001"`; the SDK discovers the
issuer there but posts the assertion to `server_url + "/token"`. For explicit
issuer mode, pass `issuer`, `discovery_url`, or `token_endpoint` as needed.

Minting a token only proves the agent could sign a DNSid assertion at exchange
time. Relying parties must still verify the returned OIDC token and perform the
DNSid subject status/revocation checks required by their trust policy.

### Signing and verifying HTTP requests (RFC 9421)

```python
from dnsid import HttpRequest, HttpSigningOptions
from dnsid.http_signatures import HttpSignatureProfile

http_sig = HttpSignatureProfile.from_identity_manager(manager)

# Sign an outbound request
req = HttpRequest(method="POST", url="https://payments-agent.example/charge", body=b'{"amount":100}')
signed = http_sig.create_signed_http_request(req)

# Verify an inbound request
vd = http_sig.verify_signed_http_request(signed)
```

### Web Bot Auth (RFC 9421)

Sign and verify DNSid WBA requests, including signed-directory discovery:

```python
from dnsid import (
    HttpRequest, WBAHttpSigner, WBAHttpVerifier, WebBotAuthVerificationOptions,
)

request = HttpRequest(method="GET", url="https://target.example/api")
WBAHttpSigner(key_provider, "agent.example").sign(request)
verifier = WBAHttpVerifier(manager)
try:
    identity = verifier.verify_web_bot_auth_signed_request(
        request,
        WebBotAuthVerificationOptions(signer_domain="agent.example"),
    )
finally:
    verifier.close()
```

The verifier requires a `RefreshingIdentityResolver` (implemented by
`IdentityManager`), not local signing keys. It verifies
both signatures, binds the directory key to the current DNSid `ku` signing key, and
applies the resolver's entity acceptance on each invocation. Supply `peer_cert`
in the options for the current application peer when `fl=mtls` applies; the
directory endpoint's certificate is not a substitute. Reuse the verifier across
requests to reuse its secure connection pool, then call `close()` at shutdown
once in-flight verifications finish. Injected transports remain caller-owned.

Discovery uses public-address-only HTTPS and rejects all redirects, including
same-host redirects. The complete operation has a default 30-second deadline;
`timeout` can lower it. Directories are fetched with `Cache-Control: no-cache`
on every invocation; injected caches must revalidate and cannot serve stale evidence.
For one-time semantics,
supply an atomic `replay_policy`
callback (see the [API reference](https://docs.dnsid.ai/reference/py/profile-web-bot-auth/)).
Without it, time validity limits reuse but does not detect it. Applications must
decide whether the covered components are sufficient: WBA requires `@authority`,
not `@method` or `@target-uri`. For operation binding, sign additional components
with `WebBotAuthSigningOptions.additional_components` and require them with
`WebBotAuthVerificationOptions.required_components`. The verifier checks exact
component coverage before discovery; its default adds no requirements.

Directories must be uncoded; any `Content-Encoding` field is rejected before
reading. Injected discovery transports must preserve bytes and enforce HTTPS,
public-address routing, and original HTTP freshness if they reuse evidence.
The verifier checks visible `Date`/`Age` metadata and rechecks positive `max-age`
deadlines before return, including after identity refresh and replay callbacks.
Fresh `no-store`, `no-cache`, and `max-age=0` responses can serve their acquiring
invocation; HTTP-stale evidence cannot.

This is the narrower DNSid WBA profile targeting published
`draft-ietf-webbotauth-httpsig-protocol-00`, including its directory format,
not a claim of general WBA or partner interoperability. A covered `Signature-Agent`
is mandatory, even with trusted caller discovery. Compatibility flags
`include_signature_agent=False` and `signature_agent=False` now raise `ArgumentError`.
For `directory`, configure an ASCII HTTPS origin without a path (including `/`),
query, fragment, or credentials; use `jwks_uri` for a direct endpoint. Directory
signatures cannot have future `created` timestamps, even within clock skew.
Nested signing is unsupported. Only the current DNSid operational key is accepted;
rotation can invalidate outstanding old-key requests without an overlap period.
After both signatures verify, a current-key mismatch permits one full identity
refresh per domain per five seconds. Concurrent retries are suppressed; failed
attempts consume the allowance. Refresh never bypasses acceptance or revocation.

WBA discovery must not identify the live `ku` endpoint, including query/fragment
variants. Supply `ku_url` to `WBAHttpSigner` when locally known to reject invalid
`jwks_uri` configuration before signing. Verification checks authenticated `ku`.
Directory serving backdates signatures by `min(5, ttl - 1)` seconds and emits signed
`max-age=N, must-revalidate`, with at most 295 seconds at the default TTL. Disable
all CDN stale-serving overrides, `stale-while-revalidate`, and `stale-if-error`.

Use the shared managed rotation/recovery workflow and pause request/directory
signing during rotation; do not publish overlapping live `ku` keys. Disable
signing and use revocation/reissuance for suspected key compromise. Cache and
publication delays can delay new-key acceptance; short TTLs do not revoke a key.
Unrestricted WBA signing-key access also authorizes `KEY_ROTATION`, not just
crawler requests. Keeping `ek` off the signing fleet does not remove that authority.
Dedicated WBA-key delegation requires a separate security design before broad
multi-node deployment.

### JWT bot bearer authentication

The legacy `WebBotAuthProfile` name is retained for compatibility. It implements
JWT bearer authentication, not RFC 9421 Web Bot Auth. Its request and client
helpers reject existing `Authorization` fields before token creation, including
empty values and client default credentials.

Authenticate outbound bot/agent HTTP requests with DNSid-issued JWTs, and
verify inbound bot identity from the `Authorization: Bearer` header.

```python
from dnsid import WebBotAuthProfile, BotIdentity, BotAuthConfig
import httpx

# Configure the bot's identity metadata
bot = BotIdentity(
    name="AcmeSearchBot",
    version="2.1",
    purpose="search-indexing",
    operator_url="https://acme.example/bot-info",
)

# Create the bot auth profile from an IdentityManager
bot_auth = WebBotAuthProfile.from_identity_manager(manager, bot=bot)

# --- Signing outbound requests ---

# Option 1: Sign a single request
request = httpx.Request("GET", "https://target.example/api/data")
signed_request = bot_auth.sign_bot_request(request)
# signed_request now has Authorization: Bearer <dnsid-jwt> and User-Agent headers

# Option 2: Create a token directly (for use with any HTTP library)
token = bot_auth.create_bot_token("https://target.example/api/data")
headers = {"Authorization": f"Bearer {token}"}

# Option 3: Auto-signing httpx client
client = bot_auth.create_signed_bot_client()
response = client.get("https://target.example/api/data")  # auto-signed

# --- Verifying inbound requests ---

# Verify a bot request (e.g. in a web framework middleware)
from dnsid import WebBotAuthProfile, BotAuthConfig

verifier = WebBotAuthProfile.from_identity_manager(
    manager,
    config=BotAuthConfig(require_bot_claim=True),
)

result = verifier.verify_bot_request(
    headers={"Authorization": "Bearer <jwt-from-request>"},
    expected_audience="https://my-server.example",  # configured server origin, not request Host
)
print(result.domain)       # "acmebot.example"
print(result.bot.name)     # "AcmeSearchBot"
print(result.bot.purpose)  # "search-indexing"
```

### Publishing your agent's identity

Draft 01 publication uses two key providers: the agent (operational) provider
passed to `IdentityManager`, and an accountable-entity provider supplied via
`IdentityManagerDependencies.entity_key_provider` that signs the TXT record
and entity lifecycle events. The two current public keys must be distinct.

```python
import os
from dnsid import DnsidConfig, IdentityConfig, IdentityManager, IdentityManagerDependencies, LocalKeyProvider

agent_key = LocalKeyProvider.generate()
entity_key = LocalKeyProvider.generate()
config = DnsidConfig(identity=IdentityConfig(
    domain="billing-agent.example",
    governance_id="billing-agent.example",
    log_ref=os.environ["DNSID_LOG_REF"],  # assigned/persisted identity-instance reference
    status_url="https://billing-agent.example/status",
    ku_url="https://billing-agent.example/.well-known/jwks.json",
    ek_url="https://billing-agent.example/.well-known/entity-jwks.json",
))
manager = IdentityManager(config, agent_key, IdentityManagerDependencies(
    entity_key_provider=entity_key,
))

# Serve these key sets over HTTPS at the configured ku and ek URLs.
ku_jwks = manager.get_key_set()
ek_jwks = manager.get_entity_key_set()

# Publish only after the registry has accepted the bilateral ISSUANCE.
txt_record = manager.create_txt_record()  # then publish at _dnsid.billing-agent.example
```

The bundled C2SP registry is **read-only**: it cannot append the ISSUANCE.
Use the registry's C2SP preparation/submission workflow to obtain and validate
its assigned `lr`, sign and submit the bilateral entry, then publish the TXT
record. The code above builds the record but does not publish it or log the
issuance. Never invent a new `lr` at each startup.

## Architecture

```
dnsid/
├── manager.py           # IdentityManager — all protocol operations
├── models.py            # All data types (DnsidConfig, IdentityConfig, VerificationConfig, TransportConfig, RegistryConfig, JWK, JWKS, DnsIdTxtRecord, VerifiedDomain, …)
├── interfaces.py        # ABCs to implement: KeyProvider, Log, LogReader, DNSResolver, IdentityCache
├── registry.py          # LogRegistry — plug in ledger method implementations
├── registry_client.py   # RegistryClient — operator-side registry workflows
├── config_loading.py    # load_environment / load_file / load_cli_directory → merge → construct
├── c2sp_tlog/           # C2SP tile-log LogReader binding (lr=c2sp-tlog:...)
├── web_bot_auth.py      # Legacy WebBotAuthProfile — JWT bot bearer authentication
├── wba_signer.py        # RFC 9421 WBA signing and directory serving
├── wba_verifier.py      # DNSid WBA request and directory verification
├── _crypto.py           # JWK thumbprint (RFC 7638) and signature verification
├── _https_client.py     # HTTPS fetching with redirect enforcement and TLS cert capture
├── _default_resolver.py # Default DNS resolver (DoH or system via dnspython)
└── _utils.py            # FQDN normalisation, base64 helpers, algorithm maps
```

## AWS KMS key provider

`AwsKmsKeyProvider` keeps private key material in AWS KMS and exposes public
keys as JWKs. Install the `aws` extra, then construct a `BotoKmsFacade` around
a boto3 KMS client:

```python
import boto3
from dnsid import AwsKmsConfig, AwsKmsKeyProvider, AwsKmsKeyState, BotoKmsFacade

facade = BotoKmsFacade(boto3.client("kms", region_name="us-east-1"))

# Load from a previously persisted state (or supply active_key_id directly)
state = AwsKmsKeyState(
    active_key_id="alias/dnsid-current",   # ARN, key ID, or alias
    retained_key_ids=[],
    pending_key_ids=[],
)

provider = AwsKmsKeyProvider.load(
    facade,
    AwsKmsConfig(
        state=state,
        algorithm="ECDSA_SHA_256",   # or "ED25519_SHA_512"
    ),
)
```

`load()` resolves aliases to canonical KMS key IDs and warms the JWK cache.

**Persisting state** — `AwsKmsKeyProvider` owns the active/pending/retained
lifecycle but does not persist state itself. Save the snapshot after any
lifecycle change and restore it on the next process start:

```python
# After generate_key(), activate(), or supersede():
snapshot = provider.state_snapshot()
save_to_database(snapshot)   # your persistence layer

# On next start, load saved state back into AwsKmsKeyState and pass to load()
```

**Key rotation**:

```python
new_kid = provider.generate_key()   # creates a new KMS key in pending state
provider.activate(new_kid)          # promotes to active; old active → retained
provider.supersede(old_kid)         # removes a retained key (optionally schedules KMS deletion)
save_to_database(provider.state_snapshot())
```

IAM permissions required at runtime: `kms:GetPublicKey`, `kms:Sign`. Add
`kms:CreateKey` for `generate_key()` and `kms:ScheduleKeyDeletion` if
`schedule_key_deletion_on_supersede=True`.

## Implementing KeyProvider

All private-key operations go through the `KeyProvider` interface — supply one that wraps your KMS or HSM to keep key material out of the process entirely:

```python
from dnsid.interfaces import KeyProvider
from dnsid import JWK

class MyKeyProvider(KeyProvider):
    def signing_key(self) -> JWK: ...
    def jwk(self, kid: str) -> JWK: ...
    def list_key_ids(self) -> list[str]: ...
    def sign(self, payload: bytes) -> bytes: ...
    def generate_key(self) -> str: ...
    def activate(self, kid: str) -> None: ...
    def supersede(self, kid: str) -> None: ...
```

## C2SP transparency-log verification

Use an identity-instance reference such as
`c2sp-tlog:public:https://log.dnsid.ai#<opaque-stream-id>` for C2SP records.
The stream ID is **not the domain** and must remain stable across restarts. Use
the `lr` assigned by your registry/CLI; when provisioning a new self-managed
stream, `generate_c2sp_tlog_stream_id()` creates a fresh 128-bit ID to persist
with that identity. Never regenerate it when loading an existing identity.

The SDK ships a ready-made [C2SP](https://github.com/C2SP/C2SP) tile-log
`LogReader`, wire-compatible with the other DNSid SDKs. It verifies proofs
but does not write events. See [interfaces.py](https://github.com/dnsid-ai/dnsid-py/blob/main/dnsid/interfaces.py)
for the reader interface. The factory returns a registry ready for `verify_domain`:

```python
from dnsid.c2sp_tlog import (
    C2spTlogVerificationOptions,
    create_c2sp_tlog_verification_registry,
)

registry = create_c2sp_tlog_verification_registry(
    C2spTlogVerificationOptions(
        policy_url="https://policy.example/dnsid-policy",
        checkpoint_freshness_ms=5 * 60 * 1000,
        max_clock_skew_ms=30 * 1000,
    )
)
```

The policy URL is independently trusted caller configuration and is never
derived from an unverified identity record, its `lr`, or its log prefix. Pass
trusted policy bytes with `policy_document` instead when appropriate. The
factory uses verified per-domain stream bundles when configured with a trust
profile, falling back to its bounded complete scanner when the bundle endpoint
is unavailable or a valid newer bundle needs raw consistency evidence. The
complete scan must prove both the stored and bundle checkpoint roots.

For an explicit application decision to trust DNSid-managed logs,
use the separately named managed factory:

```python
from dnsid.c2sp_tlog import create_dnsid_managed_verification_registry

registry = create_dnsid_managed_verification_registry()
```

It selects SDK-embedded reviewed trust only for exact canonical `public`
references to `https://log.dev.dnsid.ai`, `https://log.dnsid.ai`, or
`https://log.partners.dnsid.ai`; unknown scopes and prefixes fail closed. Every
managed log prefers verified signed stream bundles with bounded raw-scan fallback. The generic factory never selects these roots.

Both factories use a restart-ephemeral in-memory checkpoint store
by default; inject a durable `CheckpointStore` when rollback protection must
survive restarts. If `checkpoint_freshness_ms` is omitted,
non-revocation checks fail closed. Custom resource fetchers must implement the
bounded-fetch capability contract documented in the API reference. Lower-level
policy parsing, custom source construction, registration, and checkpoint-store
injection remain available
for advanced deployments.

The SDK supplies only in-memory checkpoint storage, matching the Go and TypeScript
SDKs. Reuse the same registry/store to retain observed trust. Applications that
need cross-restart protection implement the `CheckpointStore` interface and inject
it through `C2spTlogVerificationOptions(trusted_checkpoint_store=store, ...)` or
`DnsidManagedVerificationOptions(trusted_checkpoint_store=store)`, then inject the
registry into `IdentityManagerDependencies(log_registry=registry)`. Deployment
files and SDK environment variables do not configure storage.

Checkpoint failures expose stable `category` values through `VerificationError`.
Its typed `__cause__` retains continuity diagnostics or the storage failure.
Cancellation is cooperative through the invocation budget and prevents publication
when observed beforehand. Custom stores must preserve atomicity and cancellation,
fail closed on lost or corrupt state, and acknowledge durable writes only after
commit. A failed write may already have published; reconcile actual stored state
before recovery. Recovery requires independently authenticated authorization,
retained signed evidence, and fresh verification, not an automatic trust reset.
See [checkpoint implementation checks](docs/checkpoint-continuity.md).

The reader authenticates checkpoints against the pinned log and witness keys
(with anti-rollback via a checkpoint store), verifies Merkle inclusion, and
for records using `dnsid-draft-01` or pre-RFC `DNSid1` verifies the logged
lifecycle: bilateral ISSUANCE binding
of the `ek`/`ku` keys and operational-key continuity. Draft 01 verification
fails closed without a log reader capable of these checks. Current
non-revocation is operation policy, not part of `verify_domain`: callers
gating high-value or irreversible operations should call
`result.log_reader.verify_non_revocation(domain, at)` themselves (liveness is
otherwise covered by the `su` status check).
See the [transparency-log reference](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/transparency-log.md)
and [examples/a2a/startup.py](https://github.com/dnsid-ai/dnsid-py/blob/main/examples/a2a/startup.py) for a complete setup.

## Registry client (authenticated)

**Local (default).** `RegistryClient()` talks to the local registry from
`dnsid local up` at `http://127.0.0.1:7755` unless told otherwise:

```sh
dnsid local up --zone test                    # local registry, DNS, and CA in Docker
dnsid local run my-agent -- python app.py     # registers my-agent if needed, runs with DNSID_* set
```

```python
from dnsid import AgentRegistrationInput, registry_client_from_environment

# DNSID_REGISTRY_URL and DNSID_API_KEY when set; otherwise the local registry, no credential.
client = registry_client_from_environment()
client.register_agent(AgentRegistrationInput(domain="my-agent.test"))
client.verify_agent("my-agent.test")
```

To export the same variables into your shell instead of wrapping one command:
`eval "$(dnsid local env my-agent)"`. If nothing is listening, calls fail with
`no registry at 127.0.0.1:7755; run `dnsid local up` or set DNSID_REGISTRY_URL`.

**Hosted.** Set `DNSID_REGISTRY_URL` and `DNSID_API_KEY` from the console; the
same code then talks to the hosted registry. Or pass them explicitly:

```python
from dnsid import RegistryClient

client = RegistryClient("https://api.dnsid.ai", api_key="<console-issued key>")
# Legacy status reads need no credential:
RegistryClient("https://api.dnsid.ai").get_agent_status("agent.example")
```

`base_url` must be HTTPS, or HTTP on loopback. Constructors never read the
environment; only `registry_client_from_environment()` does.

Registration accepts an exact `domain`, `root_domain`, or `governance_domain`.
Public-key-only input gets a managed sandbox name. Assigned names require
`public_key_jwk`; the registry determines hosting even for an exact domain.
Explicit legacy selectors (`environment`, `managed`, `zone_id`, `tier`) are
preserved without injected defaults. Use `register_live_agent()` for managed Live.

Supply `register_agent(input, idempotency_key="...")` for retry-safe creation.
Retry with the same complete input and key. Errors retain `registration_request`,
`registration_idempotency_key`, and `registration_response` for recovery; a failure
does not prove no identity was created. Save the returned immutable `id`,
`publication_config`, and OIDC issuer. Publication authority comes from authenticated
agent detail, not the requested selectors; this configuration does not grant trust.

Hosted mutation calls require owner credentials: an organization session or API key,
passed as `api_key` and sent as an `Authorization: Bearer` header. The local
loopback registry does not require a credential. An agent
bearer token is not sufficient. This also applies to `prepare_key_rotation()`
and the other transparency-log preparation operations.

Self-managed agents prove key possession through a challenge handshake: after
`verify_agent()`, the status document exposes a single-use nonce
(`get_agent_status(domain).raw["challenge"]`); sign the base64url-decoded nonce
bytes with the active key and submit via
`submit_challenge_signature(domain, nonce, signature)`, then poll with
`wait_for_status(domain, target_state="VERIFIED")`. See
[examples/a2a/startup.py](https://github.com/dnsid-ai/dnsid-py/blob/main/examples/a2a/startup.py) for the complete
register → verify → challenge → publish sequence.

Managed Live uses the separate `register_live_agent()` operation. Sign the exact
bytes obtained by base64url-decoding its `challenge_message`, and send proof to
its derived `domain`. A successful `reissue_live_proof()` response supersedes all
older challenges: only the latest challenge and message may be signed or submitted.

Live status requires owner/session/API-key authentication because it can expose
its proof challenge; only legacy status is public. Calling a hosted mutation without
`api_key` raises `ArgumentError` before any request is sent. The credential never
appears in `repr()`/`str()`, exceptions, or logs. See the
[registry reference](https://github.com/dnsid-ai/dnsid-py/blob/main/docs/reference/registry.md) for the full method list and how to
inspect the registry-managed signed record (effective `ku`/`su`).

## Registration groundwork

Deployment files can select an existing operational key with `keySource.provider`,
`keySource.keyRef`, and non-secret `keySource.settings`. Known cloud factories load
only the selected optional package; missing packages fail without file fallback.
Constructors never generate keys. Local file selection warns against production use.

`RegistryClient.get_organization_onboarding()` explicitly reads existing account
proof/delegation status; it does not create an identity or promise permanent replay.
Pending proofs are not verified bindings, and this response supplies no entity JWKS URL.

To move an established signer, use explicit `rotate_operational_key()` with a
`target_key_provider` and stable old/target provider references. Recovery reuses those
references and exact signed bytes. Activation requires verified current publication
and rotation continuity; application signing stays paused until durable reconciliation.
Changing deployment configuration alone neither rotates nor imports a private key.

## Configuration reference

`DnsidConfig` is the single core configuration entry point:

| Section | Type | Default | Description |
|---|---|---|---|
| `identity` | `IdentityConfig` | `None` | Local identity publication settings. Omit for a verification-only manager (no key providers allowed). |
| `verification` | `VerificationConfig` | defaults | Protocol verification policy and counterparty acceptance. Same defaults with or without `identity`. |
| `transport` | `TransportConfig` | defaults | SDK-managed DNS and HTTPS settings. Never mutates injected dependencies. |

`IdentityConfig` fields:

| Field | Required | Default | Description |
|---|---|---|---|
| `domain` | yes | — | Agent FQDN (e.g. `billing-agent.example`) |
| `governance_id` | yes | — | Registrant domain (`gi` tag) |
| `log_ref` | yes | — | Persisted C2SP identity-instance reference (`c2sp-tlog:<scope>:<log-prefix>#<stream-id>`); use the reference assigned by your registry |
| `status_url` | yes | — | HTTPS URL for the agent's status endpoint |
| `policy_flags` | no | `""` | Comma-separated flags, e.g. `mtls,logchk` |
| `max_key_age` | no | `""` | Max signing key age: `24h`, `7d`, `30d`, `90d` |
| `ek_url` | no | `""` | Accountable-entity JWKS URL (`ek` tag); required for draft-01 publishing |
| `ku_url` | no | `""` | Agent operational JWKS URL; required for draft-01 publishing |
| `capabilities_url` | no | `""` | HTTPS URL for AGENTS.md or Agent Card |
| `publish_profile` | no | `""` (= `dnsid-draft-01`) | Wire profile emitted when creating records |

`VerificationConfig` fields:

| Field | Default | Description |
|---|---|---|
| `status_check_interval` | `0` (spec-strict) | Cache freshness interval for status re-checks; must be non-negative |
| `dnssec_mode` | `auto` | `auto`, `validated`, or `required` |
| `trusted_entities` | `None` | Counterparty allowlist of `TrustedEntity(governance_id, entity_key_thumbprints=None)`. `None` makes no acceptance decision; `[]` denies all. Matching is exact on the verified `gi` (no wildcard/suffix); optional pins are RFC 7638 SHA-256 thumbprints of the current record-signing key. Denials raise a permanent `VerificationError(COUNTERPARTY_NOT_ACCEPTED)` carrying only the observed `verified_governance_id` / `verified_entity_key_thumbprint`. Acceptance runs on every `verify_domain` call, including cache hits; evidence is cached before acceptance and denials are never cached. |

DNSSEC policy is explicit: `auto` accepts `VALID`, `UNSIGNED`, and `UNKNOWN`
but always rejects `FAILED`; `validated` additionally rejects `UNKNOWN`; and
`required` accepts only `VALID`. Use a custom `DNSResolver` that can distinguish
all four states when selecting `validated` or `required`.

Configuration is validated and snapshotted at construction (invalid values,
duplicate normalized entities, or malformed pins raise `ArgumentError` before
any network work). Transport settings with no SDK-managed consumer are rejected:
`dns_server` when both `dns_resolver` and `https_fetcher` are injected,
`ca_bundle_path` when `https_fetcher` is injected. Constructors never read files
or environment variables; loading is a separate step, below.

`TransportConfig` fields:

| Field | Default | Description |
|---|---|---|
| `dns_server` | `""` | Custom DNS server (e.g. `"8.8.8.8:53"`) |
| `ca_bundle_path` | `""` | Path to custom CA bundle for TLS verification |
| `private_address_hosts` | `frozenset()` | Hostnames allowed to resolve to private addresses; exact, or `.suffix` for a whole zone |

`RegistryConfig` fields:

| Field | Default | Description |
|---|---|---|
| `registry_url` | `""` | DNSid registry base URL; absent lets `RegistryClient` default to the local registry (`http://127.0.0.1:7755`), set `DNSID_REGISTRY_URL` for hosted |

### Loading configuration

Loaders parse; constructors default. Each source has one loader returning a
`LoadedConfig` with only the fields the source actually carries; `merge_loaded_config`
combines them field-wise (later wins, lists replace; `log_trust` and operational
`key_source` fields replace as groups);
`construct_identity_manager` fills `deps.log_registry` from `log_trust` and key providers from
`key_source` when the caller did not supply them, then calls the ordinary
`IdentityManager` constructor. The one-call constructors are exactly
`construct_identity_manager(merge_loaded_config(load_…(), LoadedConfig(dnsid=overlay)), key_provider, deps)`.

```python
from dnsid import (
    identity_manager_from_environment,  # DNSID_* variables
    identity_manager_from_dnsid,        # ~/.dnsid or a DNSid CLI identity directory
    identity_manager_from_file,         # JSON deployment file {"dnsid", "logTrust", "registry", "keySource"}
    load_environment, load_file, load_cli_directory, merge_loaded_config, construct_identity_manager, LoadedConfig,
)

idm = identity_manager_from_environment()          # under `dnsid local run`: keys, transport, log trust
idm = construct_identity_manager(merge_loaded_config(load_file("dnsid.json"), load_environment()))
```

| Variable | Maps to |
|---|---|
| `DNSID_DOMAIN`, `DNSID_GOVERNANCE_ID`, `DNSID_STATUS_URL`, `DNSID_LOG_REF`, `DNSID_EK_URL`, `DNSID_KU_URL`, `DNSID_PUBLISH_PROFILE`, `DNSID_CAPABILITIES_URL` | `dnsid.identity.*` |
| `DNSID_DNSSEC_MODE` | `dnsid.verification.dnssec_mode` (`auto`, `validated`, `required`) |
| `DNSID_DNS_SERVER`, `DNSID_CA_BUNDLE`, `DNSID_PRIVATE_HOSTS` (comma-separated) | `dnsid.transport.*` |
| `DNSID_LOG_POLICY_URL`, `DNSID_LOG_POLICY_FILE`, `DNSID_LOG_TRUST_PROFILE_FILE` | `log_trust` (exactly one variant; `{"managed": true}` is file/code only) |
| `DNSID_REGISTRY_URL` | `registry.registry_url` |
| `DNSID_API_KEY` | Read directly by `registry_client_from_environment()`; never included in loaded configuration |
| `DNSID_CONFIG_DIR`, `DNSID_KEY_STORE` | `key_source.cli_directory`, `key_source.key_store_path` |

Empty or whitespace-only values are absent. No loader defaults or derives a
value: without `DNSID_DOMAIN` the manager is verification-only; with
`DNSID_DOMAIN` but no `DNSID_LOG_REF`, construction fails with `ArgumentError`
(no placeholder log reference, no `status_url` derived from the registry URL).
Tooling variables (`DNSID_PUBLIC_URL`, `DNSID_AGENT_PORT`, `DNSID_SERVER`, …)
are ignored. In `merge_loaded_config`, default-valued overlays cannot clear
loaded identity strings, `transport.dns_server`/`ca_bundle_path`,
`registry.registry_url` (`""`), `verification.status_check_interval` (zero),
`transport.private_address_hosts` (empty), or `key_source` paths (`None`).
`verification.dnssec_mode=None` is also absent; set `AUTO` explicitly to
replace another mode. To clear a loaded field, edit the merged config before
`construct_identity_manager`. `trusted_entities=[]` is present and denies all.

## Examples

For status intervals, entity-key pins, and log/bundle trust, see the [trust policy example](examples/trust-policy/) and [full guide](https://docs.dnsid.ai/sdk-trust-policy/).

Runnable examples live in [examples/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples). Each subdirectory is self-contained.

| Example | Description | Prerequisites |
|---|---|---|
| [examples/local-key-provider/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples/local-key-provider) | File-backed local key provider demo | None — fully self-contained |
| [examples/published-signing/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples/published-signing) | Load deployment config, verify an existing identity, and sign with AWS KMS or file custody | Published identity and its existing operational key |
| [examples/a2a/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples/a2a) | Two agents (Alice + Bob) exchanging RFC 9421-signed A2A messages | Local registry (Docker + DNSid CLI) |
| [examples/validate-domain/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples/validate-domain) | Verify a domain's DNSid identity | Network access — defaults to a hosted sandbox identity and the managed production log |
| [examples/webbotauth/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples/webbotauth) | Sign and verify bot HTTP requests with `WebBotAuthProfile` | None — fully self-contained |
| [examples/oidc/](https://github.com/dnsid-ai/dnsid-py/tree/main/examples/oidc) | Mint and inspect a DNSid OIDC assertion with `OIDCProfile` | None offline; published identity + OIDC endpoint for opt-in exchange |

### Self-contained example (no local registry needed)

The local-key-provider example runs entirely offline — it demonstrates key
lifecycle, JWKS construction, and JWT signing without any network dependencies:

```bash
source .venv/bin/activate
python examples/local-key-provider/main.py
```

### Local-registry examples

The `a2a` example requires the local registry managed by the `dnsid` CLI
([installation guide](https://docs.dnsid.ai/cli-installation);
`dnsid local up --zone test` / `dnsid local run`), which provides local DNS, a
CA, and a registry. See
[examples/a2a/README.md](https://github.com/dnsid-ai/dnsid-py/blob/main/examples/a2a/README.md) for full prerequisites and
step-by-step instructions.

### Validate-domain example (no local registry needed)

Verifies the default hosted sandbox identity against DNSid's managed production log.
See the [example README](https://github.com/dnsid-ai/dnsid-py/blob/main/examples/validate-domain/README.md)
for local verification of `bob.test`:

```bash
source .venv/bin/activate
python examples/validate-domain/main.py
```

## Development

```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

# Run tests
pytest

# Lint
ruff check dnsid/

# Type check
mypy dnsid/
```

## Security & trust

**Official sources.** Source: `github.com/dnsid-ai/dnsid-py`. Package: `dnsid` on PyPI. Releases: GitHub Releases on
this repository, each with a CycloneDX SBOM attached. Forks, mirrors, and similarly named packages
are not maintained by us. Report vulnerabilities per [SECURITY.md](SECURITY.md); never in a public issue.

**Software is not identity.** This SDK ships no private keys or credentials; it bundles
public log trust roots used only when the managed-log factory is selected. A DNSid identity is proven
by control of a DNS zone, an agent private key, and the registry's published status. Possessing, forking,
or modifying this code grants none of those: an unofficial build cannot mint or inherit anyone's identity.

**What it does on the network.** Only when you call it, and only to hosts you or the domain being verified
chose:

- DNS TXT lookup of `_dnsid.<domain>` through your system resolver (no hardcoded resolver)
- HTTPS GET to the JWKS and status URLs published in that TXT record
- Opt-in only, never contacted unless you configure them: `https://api.dnsid.ai` (registry client), `https://log.dnsid.ai` / `log.dev.dnsid.ai` / `log.partners.dnsid.ai` (C2SP transparency log, bundled public trust roots), cloud KMS endpoints
- No telemetry, usage reporting, update checks, or crash reporting

**Logging.** None today. A standard-library `logging` logger under the `dnsid` namespace is declared for future
use; if it ever emits, it will carry key thumbprints and verification states, never private keys, tokens, or
signatures. Errors are raised to the caller.

**Hosted endpoints.** `api.dnsid.ai` and `log.dnsid.ai` are operated separately from this SDK under their
own terms. Nothing in this repository is an availability, uptime, or support commitment for them.

**For your privacy notice.** Using this SDK causes your system to make DNS and HTTPS requests to the domains
you verify and to the JWKS/status hosts they publish. It sends them nothing about your users. If you enable
the registry or transparency-log clients, requests also go to DNSid-operated endpoints; disclose that where
your notice requires it.

## License

[Apache License 2.0](https://github.com/dnsid-ai/dnsid-py/blob/main/LICENSE.txt)
