diff --git a/README.md b/README.md index 44b196534..a099c818d 100644 --- a/README.md +++ b/README.md @@ -1597,10 +1597,6 @@ See `examples/fetch_agent_authorizations.py` for complete examples. ## Request Signing (AdCP 3.0 optional, 4.0 required) -AdCP 3.3 identity support is available as an opt-in preview: see the -[Web Bot Auth and trust.json migration guide](docs/agent-identity-3-3-migration.md) -for signing, directory publishing, origin binding, demand grants, and preview types. - AdCP defines an optional transport-layer request-signing profile based on [RFC 9421 HTTP Message Signatures](https://datatracker.ietf.org/doc/rfc9421/). A valid signature proves the request came from the agent whose key signed it. diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md deleted file mode 100644 index 89419db73..000000000 --- a/docs/agent-identity-3-3-migration.md +++ /dev/null @@ -1,161 +0,0 @@ -# Experimental AdCP 3.3 agent identity - -This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile and identity schemas are pinned to the draft commits recorded in `tests/conformance/vectors/wba-profile/README.md` and `src/adcp/signing/_identity_schemas/UPSTREAM.json`. They may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. - -## Buyer signing and publishing - -Give each agent its own HTTPS origin. Generate a key using the existing keygen helpers, then derive its WBA public JWK and thumbprint: - -```python -from adcp.signing import ( - SigningConfig, load_private_key_pem, public_directory_key, sign_key_directory, -) - -private_key = load_private_key_pem(private_pem) -public_jwk = public_directory_key(private_key) -signing = SigningConfig( - private_key=private_key, - key_id=public_jwk["kid"], - agent_origin="https://buyer.agency.com", -) -# Pass signing=signing to ADCPClient or install_signing_event_hook. -``` - -`agent_origin` explicitly selects Web Bot Auth, sets and covers the structured `Signature-Agent` member, uses `tag="web-bot-auth"`, and uses standard Base64 signatures regardless of the legacy wire encoding. The signer derives the algorithm from the public key, generates a nonce, and binds every nonempty body with its digest. Keep header emission opt-in until all counterparties, including Go verifiers, tolerate the additional covered component. Automatic signing also requires `web-bot-auth` in the seller's `request_signing.profiles`; omission advertises legacy support only. An unsupported configured profile raises before sending and never silently downgrades. - -Serve a signed directory response at `/.well-known/http-message-signatures-directory` on that origin: - -```python -response = sign_key_directory( - agent_origin="https://buyer.agency.com", - private_keys=[private_key], -) -# Serve response.body as the exact response bytes, with response.headers. -``` - -The helper signs once per key, covering the request authority and content digest. Recreate and republish the response before its signatures expire. Publish new keys before using them; cached directories expire within five minutes. A publisher must not expose private key material in the directory. - -## Seller verification and binding - -```python -from adcp.server import RequestSignatureVerification, serve -from adcp.signing import ( - DiscoveredSignerKeys, OriginBindings, OriginRevocationChecker, TrustRecordEvaluator, -) - -keys = DiscoveredSignerKeys() -bindings = OriginBindings() # One-process example; production supplies a durable resolver. -records = TrustRecordEvaluator(polling_interval_seconds=60) -verification = RequestSignatureVerification( - signer_keys=keys, - enable_web_bot_auth=True, - origin_bindings=bindings, - trust_records=records, - wba_revocation_checker=OriginRevocationChecker(keys), - required_for=frozenset({"sync_accounts", "create_media_buy"}), - # Supply a shared replay_store for multiple processes/replicas. -) -serve(handler, request_signature_verification=verification) -``` - -A presented `Signature-Agent` or `web-bot-auth` tag selects WBA exclusively, including when verification is in shadow mode. Signature parsing, failed signatures/replays, and verification capacity failures use HTTP 400, 403 with `Accept-Signature`, and 429, with a generic response body. Every WBA signature verifies, and a relay must cover the earlier signers' signature fields. The originating signer remains the AdCP caller. Once identity is established, authorization failures use the standard AdCP error envelope; a correctly verified request never receives an `Accept-Signature` challenge. - -Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` labels are malformed. Verification rejects them before key discovery or replay admission: WBA returns HTTP 400, and the RFC 9421 profile returns `request_signature_header_malformed`. Parameters on different covered components remain independent, and quoted delimiters do not create extra members or parameters. - -`DiscoveredSignerKeys` resolves `(covered origin, thumbprint)` from a signed directory. It never calls capabilities or reads brand.json or trust.json. `verify_wba_request` is the standalone async verifier; it returns verified origins without granting authority. The legacy synchronous verifier rejects WBA markers and cannot be used as a fallback. - -When a key directory cannot be fetched or a revocation list is stale past grace, the request receives retryable `SERVICE_UNAVAILABLE`. It establishes no identity and performs no business operation; it does not silently fall back to anonymous access. A fetched directory without the requested key, or a directory returning `404` or `410`, remains a signature failure with the WBA challenge. - -To continue accepting configured RFC 9421 buyers on the same endpoint, pass their existing `StaticSignerKeys` or `JwksUriSignerKeys` as `DiscoveredSignerKeys(legacy_resolver=...)`. Only messages selected for the legacy profile use this resolver. A failed WBA discovery never falls back to it. Concurrent directory lookups coalesce per origin and do not block unrelated signers. Successful directory history admits at most `max_origins=256` origins and `max_origins_per_operator=8` origins per registrable operator. It does not evict a previously admitted origin to make room for a new one. Unauthenticated initial failures use a separate bounded cooldown cache. - -A verified unbound caller can invoke `get_adcp_capabilities` and `get_principal` by default. Configure `unbound_for` to match the handler's other anonymous permissions, such as public `get_products` and `list_creative_formats`, and JSON-RPC methods such as `initialize` or `tools/list` when they are public. Include `sync_principal` only when the application implements an explicit open onboarding policy that checks trust, requirements and billing before approval. These operations receive an established origin with no principal or account authority. `get_principal` implementations should return the preview's `unconfigured` result when no durable principal exists. Discovering a key never creates or upserts an account. To approve a binding, evaluate the operator's record explicitly: - -```python -await records.bind( - verified_signer, - principal_id="principal_123", - bindings=bindings, -) -``` - -The record must list exactly one agent at the verified origin with `adcp:buying`. Migrating an existing RFC 9421 principal requires `existing_rfc9421_principal=True, migration_approved=True` and a seller-side approval decision. The same origin cannot be rebound to another principal. Once bound, legacy signatures for that origin are rejected. Persist bindings and the origin's commercial identity in the seller's registry; the registry still governs suspension, billing and account access. - -The framework preserves the verified origin as caller identity and exposes the binding's principal ID in `context.metadata["adcp.auth_info"].extra["principal_id"]`. Use that seller-owned ID when resolving durable principal/account state during an approved migration; the transport does not rewrite application records. - -A stored binding records seller approval, not permanent operator membership. Framework binding authorization re-evaluates the operator's agent entry using `trust_records` and its polling-bounded cache, including when reusing a trusted edge's verified signer. `binding_role` defaults to `adcp:buying`; configure the required role for other receiving surfaces. A delisted origin falls back to identity-established access: the application's anonymous operations and `get_principal`, with no account authority. It can still poll and cancel tasks it created while listed, as specified in [the first-contact rules](https://github.com/adcontextprotocol/adcp/blob/c1ce495b380d4b98f6892939b0eb5e137ce20d2c/docs/building/by-layer/L2/accounts-and-agents.mdx). Those controls retain the original task owner without restoring its principal or allowing new work under the historical binding. It cannot bind again while unlisted. Keep its historical binding to prevent a legacy downgrade. - -An unreachable trust record does not delist a bound origin. The last record read stays authoritative, including a delisting or removal. Spend-committing calls fail with retryable `SERVICE_UNAVAILABLE`; other calls can continue under existing permissions only within four polling intervals after the last listing record's freshness deadline. Without a prior listing, or after that grace window, they also receive `SERVICE_UNAVAILABLE`. A `404` or `410` after a record was read is removal rather than an outage. A failed binding or trust-record match uses `PERMISSION_DENIED`. These authority checks run after transport verification; key discovery still reads no trust record. - -`RequestSignatureVerification.spend_classifier(operation, params)` lets a seller classify spending using its own state. The default treats new purchases, acquisitions, resumes, financial changes, and unknown operations as spend-committing; name changes, pauses, and cancellations can continue under a cached binding. Budget changes are conservative because the verifier cannot compare them with seller-owned balances. The trusted decision also governs the account grant gate. - -Framework `serve` dispatch projects authorization failures through the existing MCP and A2A error bindings before handler side effects. Standalone `SignedRequestVerificationMiddleware` rejects denied requests before entering a custom app by default. Set `defer_authorization_errors=True` only when the integration applies the verified context with `apply_verified_signer` and calls `check_wba_authorization` from `adcp.server.signed_requests` before its handler. A custom A2A `request_handler` also keeps the default rejection boundary. Applications still enforce anonymous permissions and task ownership. - -For `get_principal`, an identity-established or delisted caller with `principal_id=None` receives `unconfigured` and any advisory relationship; never resolve historical principal configuration from its origin alone. After approval, read `get_principal` again using the bound identity. The built-in A2A handler checks the owner-scoped task store before cancelling or subscribing to an active task. A custom `request_handler` must enforce equivalent ownership checks. The installed MCP server has no task-control implementation; accepting an authenticated task method does not add one. A2A tasks created under an anonymous owner do not automatically move to the bound owner. Keep anonymous task polling and controls disabled unless the application's task ownership policy supports them. - -Framework WBA verification installs `OriginRevocationChecker` by default to read signed `adcp-gov-revocation+jws` lists from the verified origin. Supply a custom `wba_revocation_checker` for shared or durable revocation state; standalone `verify_wba_request` callers must pass a checker before relying on the identity for authorization. Both revocation checker variants retain revoked identifiers across refreshes and keep `keyCompromise` and the earliest `invalidity_date`. An observed undated compromise stays undated, so a later date cannot narrow its exposure window. `RevocationDetails.exposes(received_at)` uses seller receipt time to assess compromise exposure. Durable applications should persist revocation history across process restarts. The origin checker refuses new origins at capacity instead of forgetting retained revocations. - -## Trust records and demand grants - -The built-in origin revocation checker admits at most 256 origins and eight per registrable operator by default. Initial discovery without an authenticated list consumes only a bounded 60-second cooldown entry. Authenticated signed history, including revocations in stale responses, is retained; a stale response still cannot authorize a request past grace. Configure `max_origins` and `max_origins_per_operator`, or supply a durable checker with seller-owned admission policy for larger deployments. Discovery caches coalesce requests per origin/domain so one slow counterparty does not block another tenant. - -Revoked key and token facts also have non-evicting limits: `max_facts_per_origin=2048`, `max_facts_per_operator=8192`, and `max_history_facts=65536`. If an authenticated list exceeds a limit, that origin's checker stays unavailable for its lifetime, retaining its earlier facts; a later list omitting the refused revocations cannot restore access. Other admitted origins keep their history. Size these limits for the deployment, and persist admission decisions and revocation history when supplying a durable checker. - -`TrustRecordEvaluator` derives `https://{eTLD+1}/.well-known/trust.json` using the SDK's pinned ICANN and PRIVATE PSL snapshot. It follows one `authoritative_location` pointer while preserving the original owner, follows no HTTP redirects, caps bytes at 2 MiB, rejects ambiguous JSON, and caches no longer than the configured revocation polling interval. Keep a custom polling interval no longer than the identity's revocation cadence; the default is 60 seconds. Fresh approval requires current records; outage grace is an explicit option for existing non-spend access and never replaces a newer negative record. A 200 response permanently disables legacy fallback for that host during the evaluator's lifetime, including after a later disappearance or fetch failure. - -Host failure cooldowns are scoped to the original record owner, so an owner's pointer cannot suppress another owner's discovery. Content errors do not cool down a host. The default limits allow 256 pending records and four concurrent fetches per destination host; local saturation fails closed without caching a failure, so callers can retry as soon as capacity is available. Shared transport responses still receive separate ownership validation. - -Outage continuity rechecks the latest retained policy for every owner, even when another origin or claim fetched it. Removals and invalid records supersede earlier approvals. `max_history_records` admits at most 256 registrable owners by default, separately from the ephemeral `max_records` cache limit. Each owner uses one history slot. Published-record and policy facts are retained without eviction; when history is full, a new owner receives retryable `SERVICE_UNAVAILABLE` before fetching. An initial `404` or `410` also retains admission, so an established legacy grantor can refresh after the pool fills; its absence cache still expires, and admission alone never authorizes outage fallback. Existing owners can still refresh, and their publication and removal evidence stays authoritative. A final snapshot and clock check prevents a concurrent refresh or slow callback from extending older authority. - -`validate_trust_record(document, operator_domain=...)` validates authoring output, rejects keys in agent entries, checks ownership, and enforces one agent per origin. The record's host is its owner; a hosted document cannot name another owner inside its content. - -`TrustGrantEvaluator.check(...)` evaluates authority from the brand's record. It recognizes same-domain implicit authority and direct or one-intermediary chains that include `account.operator`. A self-operated brand can grant the signer's operator directly: the brand itself is `account.operator` on that chain. Every link must cover the requested scope, brand, countries, agent narrowing, and validity window. It intersects authority and cannot follow a third delegation link. `exclusive_grants` disables discretionary authorization for scopes named by the record. The optional `seller_authorization` hook consults seller-owned onboarding policy; `legacy_authorization` runs only when the brand has never successfully published trust.json and its initial fetch returns 404/410. Fetch failures cannot establish new authority. Explicit outage continuity rechecks retained records and preserves every grant's hard validity deadline. - -For `DecisioningPlatform`, wire a gate and an active buyer-agent registry: - -```python -from adcp.decisioning.trust_grants import TrustGrantClaim, TrustGrantGate -from adcp.signing import TrustGrantEvaluator - -# Extract existing-account brand/operator/countries from seller-owned state. -gate = TrustGrantGate( - evaluator=TrustGrantEvaluator(records), - extract_claim=lambda account, agent: TrustGrantClaim( - brand_domain=account.metadata["brand_domain"], - account_operator=account.metadata["operator"], - brand_id=account.metadata.get("brand_id"), - countries=tuple(account.metadata.get("countries", ())), - ), -) -# adcp.decisioning.serve(..., buyer_agent_registry=registry, trust_grant_gate=gate, -# request_signature_verification=verification) -``` - -The gate checks every natural-key `sync_accounts` declaration before any upsert. Existing account IDs and account-scoped calls use the seller's extractor, including a fresh check before spending. An uncovered grant raises `PERMISSION_DENIED` with `{"scope": "agent", "reason": "grant_absent"}` after commercial identity is established. A failed trust-record match uses `PERMISSION_DENIED` without grant details; an outage requiring fresh authority uses retryable `SERVICE_UNAVAILABLE`. Existing bound accounts can continue non-spend calls only under previously read authority within its grace window; provisioning still requires a current grant. A grantor's record removed after a read means `grant_absent`, and cannot restore legacy authorization. A known grant denial persists until a fresh successful check; an outage cannot restore withdrawn authority. `max_denials` bounds the combined process-local claim history (default 4,096), with `max_claims_per_operator` limiting each signer's registrable operator to 128 claims. Admission reserves a slot before evaluation. Overflow refuses the new claim without forgetting retained approvals or denials, or disabling other operators' outage continuity. Persist withdrawals in seller-owned state when continuity across restarts is required. The gate changes no account status. Supply equivalent checks in custom `ADCPHandler` implementations before account side effects. - -Typed client results preserve `adcp_error`, including its code, recovery guidance, and details. A `SERVICE_UNAVAILABLE` result has transient recovery and can be retried after backoff; `PERMISSION_DENIED` requires an authorization change. - -Only a previously approved claim can use outage grace. Successful proofs and denials share the bounded claim slots and remain available until a fresh check changes their authority. An outage read never refreshes a proof or extends an explicit grant's validity window. - -When configured, this gate replaces the older `brand_authorization_gate` for account-scoped dispatch. Put any permitted brand.json fallback in `TrustGrantEvaluator(legacy_authorization=...)`; applying both gates independently would wrongly consult `authorized_operators` after a successful trust.json fetch. - -The framework passes the trusted transport profile into this gate. Legacy RFC 9421 and bearer buyers can use the configured legacy authorization hook without an operator trust record while the brand has never published trust.json. Once the brand returns 200, both profiles use fresh operator membership and trust grants; a missing record or fetch failure cannot restore the old path. - -`TrustGrantClaim.scope` defaults to `adcp:media_buying` for buying and account provisioning. For other domains, configure `extract_operation_claim(account, buyer_agent, operation)` to select an explicit scope from the framework's operation name and seller-owned state. A media-buy grant cannot authorize creative generation merely because both calls name an account. - -## Preview types and rollout - -Use the preview `RequestSigning` model to advertise `profiles=["adcp/request-signing/v1", "web-bot-auth"]` on a server accepting both. The preview reader models preserve unknown onboarding requirement kinds so applications can fall back to the posture's message and URL; unknown kinds cannot be satisfied automatically. Authoring validation still enforces the pinned draft schema. - -For preview `principal.changed` reasons, pass `experimental_agent_identity=True` to `WebhookSender.prepare_principal_changed` or `send_principal_changed`, and again to `send_prepared` or `resend` when publishing persisted events. The shared validator retains the stable envelope checks and permits only the two draft relationship reasons under this explicit option. - -Import experimental models from `adcp.types.experimental_identity`: `AgentOnboarding`, its modes/tiers/requirements, `BuyerOnboardingPosture`, `TrustRecord`, `GetPrincipalResponse`, `PrincipalRelationship`, `PrincipalChangedWebhook`, and `GrantAbsentDetails`. The response wrappers preserve existing fields while adding typed onboarding and relationship fields. Both new relationship webhook reasons are accepted. Generate the private models offline with `python scripts/generate_identity_preview.py`; validate behavioral constraints with the trust helpers. - -Use `ADCPClient(..., experimental_agent_identity=True)` or the equivalent multi-agent option to preserve and parse these fields on capabilities, principal reads and completed-task callbacks. WBA signing opts into these response models automatically. Stable 3.2 schema validation remains tied to the wire pin; validate new identity fields against the preview schemas rather than treating the stable bundle as a 3.3 conformance bundle. - -`PrincipalService(relationship_resolver=...)` accepts an async seller-owned lookup keyed by the authenticated `PrincipalIdentity`. It can return a `PrincipalRelationship` model, a mapping or `None`. Reading a pending application can therefore return `unconfigured` with a relationship without creating a durable principal or authorizing an account. - -Onboarding posture is advisory. Sellers still implement requirements, applications and billing policy. Evaluate the verified origin's trust record and required role with `records.evaluate(...)` when applying an `open` policy, before provisioning access. An `open` sandbox tier grants sandbox access only; live spending requires a billing relationship and applicable brand grants. A mode absent from the posture stays undeclared. - -RFC 9421 configuration emits a `DeprecationWarning` and continues to work through 3.x. `async_resolve_agent`, `verify_from_agent_url`, BrandJsonJwksResolver and governance/rights/document JWS verification retain their existing chain. Their JWS migration is deferred to AdCP 3.4. Roll back opt-in signing only for origins that have not been bound; rolling a bound origin back to RFC 9421 requires explicit seller migration, not an automatic fallback. - -The draft WBA transport vectors are vendored and tested. Released P7 trust-record and grant-chain vectors and the final 3.3 schema bundle remain upstream release dependencies; update the pinned previews and run conformance again when they land. The upstream migration-guide SDK table must be updated when a release includes this support. diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index 1ae8d366a..814918521 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -1,9 +1,5 @@ # Migrating to signed AdCP requests -For the experimental AdCP 3.3 origin identity profile, see -[Web Bot Auth and trust.json migration](agent-identity-3-3-migration.md). -The guide below continues to cover the RFC 9421 profile supported through 3.x. - Rolling out RFC 9421 request signing against an existing AdCP integration is a two-track exercise: **bootstrap** once, then **enforce** in stages per operation. Key **rotation** follows the same pattern and is meant to be routine. This guide covers the operator-facing mechanics. Spec reference: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer). diff --git a/pyproject.toml b/pyproject.toml index 2aebdd30a..85a37b7e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,6 @@ dependencies = [ # httpx2 2.5.0 requires idna>=3.18. Upper bound at <4 because IDNA # semantics across a major are not contracted. "idna>=3.18,<4", - "http-sfv>=0.9.9,<1.0", ] [project.scripts] @@ -229,8 +228,6 @@ namespaces = true [tool.setuptools.package-data] adcp = [ - "signing/_identity_schemas/**/*.json", - "signing/_identity_schemas/*.json", "py.typed", "types/*.pyi", "types/versioned_bases/*.pyi", @@ -296,7 +293,6 @@ extend-exclude = "/(tasks|_generated|registry)\\.py$|/types/domains/|/types/v3[0 line-length = 100 target-version = "py310" extend-exclude = [ - "src/adcp/types/_identity_preview.py", "src/adcp/types/_generated.py", # datamodel-code-generator output; regenerate with # scripts/vendor_reporting_status_preview.py rather than hand-editing. diff --git a/scripts/generate_identity_preview.py b/scripts/generate_identity_preview.py deleted file mode 100644 index f00891975..000000000 --- a/scripts/generate_identity_preview.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Generate experimental identity types from pinned draft schemas, offline. - -The stable AdCP wire pin and generated domain tree are intentionally unchanged. -The two onboarding slots are an explicit composition of independent P5/P6 drafts. -""" - -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -PREVIEW = ROOT / "src/adcp/signing/_identity_schemas" -STABLE = ROOT / "schemas/cache/3.2" -ENTRIES = ( - "core/agent-onboarding.json", - "core/buyer-onboarding-posture.json", - "core/principal-relationship.json", - "trust/v1/trust.json", -) - - -def build_bundle() -> dict: - definitions = {} - visited = set() - - def key(path): - return path.removesuffix(".json").replace("/", "_").replace("-", "_") - - def load(path): - if path in visited: - return - visited.add(path) - source = PREVIEW / path - if not source.exists(): - source = STABLE / path - schema = json.loads(source.read_text()) - # Compose the independently proposed buyer posture slots without editing - # the immutable source schemas or claiming the composition is a release. - if path == "trust/v1/trust.json": - for name in ("agent", "record_profiles"): - profiles = ( - schema["definitions"][name]["properties"]["profiles"] - if name == "agent" - else schema["definitions"][name] - ) - profiles["allOf"][1]["properties"]["adcp"]["properties"]["onboarding"] = { - "$ref": "/schemas/core/buyer-onboarding-posture.json" - } - - def walk(value): - if isinstance(value, dict): - value.pop("$id", None) - if "$ref" in value: - reference = value["$ref"] - if reference.startswith("#"): - value["$ref"] = "#/definitions/" + key(path) + reference[1:] - else: - target, _, fragment = reference.partition("#") - target = target.split("/schemas/", 1)[1] - if target.startswith("v3/") or target.split("/", 1)[0][0].isdigit(): - target = target.split("/", 1)[1] - load(target) - value["$ref"] = ( - "#/definitions/" - + key(target) - + ("/" + fragment.lstrip("/") if fragment else "") - ) - for item in value.values(): - walk(item) - elif isinstance(value, list): - for item in value: - walk(item) - - walk(schema) - definitions[key(path)] = schema - - for path in ENTRIES: - load(path) - return { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "IdentityPreview", - "definitions": definitions, - "anyOf": [{"$ref": "#/definitions/" + key(path)} for path in ENTRIES], - } - - -def main(): - bundle = build_bundle() - (PREVIEW / "bundle.json").write_text(json.dumps(bundle, indent=2) + "\n") - target = ROOT / "src/adcp/types/_identity_preview.py" - with tempfile.TemporaryDirectory() as directory: - schema = Path(directory) / "identity-preview.json" - schema.write_text(json.dumps(bundle)) - subprocess.run( - [ - sys.executable, - "-m", - "datamodel_code_generator", - "--input", - str(schema), - "--input-file-type", - "jsonschema", - "--output", - str(target), - "--output-model-type", - "pydantic_v2.BaseModel", - "--target-python-version", - "3.10", - "--use-union-operator", - "--use-standard-collections", - "--disable-timestamp", - "--use-schema-description", - "--use-subclass-enum", - "--use-annotated", - "--field-constraints", - ], - check=True, - ) - # Codegen still emits a constrained type call for propertyNames used as - # dict keys. Annotated preserves the runtime constraint and is valid for - # static type checkers (including inside a RootModel generic base). - text = target.read_text().replace( - "constr(pattern=r'^[a-z0-9-]+$')", - "Annotated[str, Field(pattern=r'^[a-z0-9-]+$')]", - ) - # Readers retain future requirement kinds so they can show the posture's - # human fallback instead of discarding the whole capability response. - # The immutable schema bundle remains strict for authoring validation. - text = text.replace( - "kind: EnumsAgentOnboardingRequirementKind", - "kind: Annotated[EnumsAgentOnboardingRequirementKind | str, " - "Field(union_mode='left_to_right')]", - ) - text = text.replace(" constr,\n", "") - target.write_text(text) - subprocess.run([sys.executable, "-m", "black", str(target)], check=True) - print(f"Generated {target.relative_to(ROOT)} from pinned drafts") - - -if __name__ == "__main__": - main() diff --git a/src/adcp/_notification_envelope.py b/src/adcp/_notification_envelope.py index b762de9a4..5d688d493 100644 --- a/src/adcp/_notification_envelope.py +++ b/src/adcp/_notification_envelope.py @@ -42,7 +42,6 @@ def validate_notification_body( idempotency_key: str | None = None, notification_type: str | None = None, account_id: str | None = None, - experimental_agent_identity: bool = False, ) -> dict[str, Any]: """Reject malformed envelopes before committing and again before HTTP. @@ -77,17 +76,7 @@ def validate_notification_body( validator = get_named_validator(NOTIFICATION_SCHEMAS[kind]) if validator is None: raise ValueError("bundled notification schema is unavailable") - schema_payload = payload - if ( - experimental_agent_identity - and kind == "principal.changed" - and isinstance(payload.get("reason"), str) - and payload.get("reason") in {"relationship_changed", "relationship_expiring"} - ): - # The pinned draft adds two enum values; all other wire constraints - # still come from the stable offline schema. Preserve delivery bytes. - schema_payload = {**payload, "reason": "other"} - error = next(validator.iter_errors(schema_payload), None) + error = next(validator.iter_errors(payload), None) if error is not None: raise ValueError(f"notification envelope failed schema validation ({error.validator})") if kind.startswith(("product.", "signal.")) or kind == "wholesale_feed.bulk_change": diff --git a/src/adcp/client.py b/src/adcp/client.py index 1824edfef..5030ccfed 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -87,7 +87,6 @@ from adcp.signing.autosign import ( SigningConfig, operation_needs_signing, - require_signing_profile, signing_profile_for_adcp_version, ) from adcp.signing.autosign import ( @@ -720,7 +719,6 @@ def __init__( account_policy: AccountPolicy | None = None, account_storage: AccountStorage | None = None, raise_account_errors: bool | None = None, - experimental_agent_identity: bool = False, product_property_policy: ProductPropertyPolicy | None = None, ): """ @@ -731,8 +729,6 @@ def __init__( product_property_policy: Optional buyer-side property-list check; filters or flags seller products and reports unjudgeable inventory in TaskResult.metadata. Disabled by default. - experimental_agent_identity: Parse the pinned AdCP 3.3 relationship and - onboarding response types. WBA signing enables this automatically. webhook_url_template: Template for webhook URLs with {agent_id}, {task_type}, {operation_id} webhook_secret: Shared secret for the deprecated HMAC-SHA256 webhook @@ -920,9 +916,6 @@ def __init__( self.validate_features = validate_features self.strict_idempotency = strict_idempotency self.signing = signing - self.experimental_agent_identity = experimental_agent_identity or ( - signing is not None and signing.agent_origin is not None - ) self.product_property_policy = product_property_policy if httpx_client_factory is not None and not callable(httpx_client_factory): raise TypeError("httpx_client_factory must be callable") @@ -1489,7 +1482,6 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None: decision = operation_needs_signing(req_signing, operation) if decision == "skip": return - require_signing_profile(req_signing, self.signing) covers_policy: str | None = None if req_signing is not None and req_signing.covers_content_digest is not None: @@ -1516,7 +1508,6 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None: alg=self.signing.alg, cover_content_digest=cover_digest, tag=self.signing.tag, - agent_origin=self.signing.agent_origin, signing_profile_version=signing_profile_version, ) # pop-then-set ensures our signed values are authoritative even if @@ -2239,7 +2230,6 @@ async def _execute_typed_task( response_type: type[BaseModel] | Any, ) -> TaskResult[Any]: """Execute and parse one typed AdCP task with activity events.""" - response_type = self._identity_response_type(response_type) operation_id = self._task_operation_id() params = request.model_dump(mode="json", exclude_none=True) # AdCP 3.2 mutation inputs are three-state: omitted leaves the stored @@ -2273,7 +2263,7 @@ async def _execute_typed_task( timestamp=datetime.now(timezone.utc).isoformat(), ) ) - parsed_result: TaskResult[Any] = self.adapter._parse_response(raw_result, response_type) + parsed_result = self.adapter._parse_response(raw_result, response_type) if task_type == "refine_proposals" and isinstance(raw_result.data, Mapping): metadata = dict(parsed_result.metadata or {}) metadata[WIRE_RESPONSE_METADATA_KEY] = raw_result.data @@ -3995,20 +3985,7 @@ async def get_adcp_capabilities( ) ) - return self.adapter._parse_response( - raw_result, self._identity_response_type(GetAdcpCapabilitiesResponse) - ) - - def _identity_response_type(self, response_type: type[R]) -> type[R]: - """Use the pinned draft response wrappers only for opted-in clients.""" - if self.experimental_agent_identity: - from adcp.types import experimental_identity - - if response_type is GetPrincipalResponse: - return cast(type[R], experimental_identity.GetPrincipalResponse) - if response_type is GetAdcpCapabilitiesResponse: - return cast(type[R], experimental_identity.GetAdcpCapabilitiesResponse) - return response_type + return self.adapter._parse_response(raw_result, GetAdcpCapabilitiesResponse) @_task_options_method async def sync_agent_notification_configs( @@ -6179,7 +6156,6 @@ def _parse_webhook_result( return cast(TaskResult[AdcpAsyncResponseData], projected) response_type = response_type_map.get(task_type) if response_type: - response_type = self._identity_response_type(response_type) try: parsed_result: Any = parse_json_or_text(result, response_type) metadata: dict[str, Any] = { @@ -6746,7 +6722,6 @@ def __init__( canonical_format_legacy_resolver: CanonicalFormatLegacyResolver | None = None, allow_unauthenticated_webhooks: bool | Mapping[str, bool] = False, httpx_client_factory: MCPHttpxClientFactory | None = None, - experimental_agent_identity: bool = False, product_property_policy: ProductPropertyPolicy | None = None, ): """ @@ -6756,8 +6731,6 @@ def __init__( agents: List of agent configurations product_property_policy: Optional property-list policy forwarded to each per-agent client. - experimental_agent_identity: Enable draft identity response types for - every per-agent client; WBA signing also enables them. webhook_url_template: Template for webhook URLs webhook_secret: Shared secret for the deprecated HMAC-SHA256 webhook fallback. Configure only for registrations that explicitly @@ -6835,7 +6808,6 @@ def __init__( webhook_secret=webhook_secret, on_activity=on_activity, signing=signing, - experimental_agent_identity=experimental_agent_identity, product_property_policy=product_property_policy, httpx_client_factory=( httpx_client_factory if agent.protocol == Protocol.MCP else None @@ -6857,7 +6829,6 @@ def __init__( webhook_secret=webhook_secret, on_activity=on_activity, signing=signing, - experimental_agent_identity=experimental_agent_identity, product_property_policy=product_property_policy, httpx_client_factory=( httpx_client_factory if agent.protocol == Protocol.MCP else None diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index cda60e1f8..d7bc4d74d 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -271,7 +271,6 @@ class _PublicAccount(_DecisioningAccount[Any]): from adcp.decisioning.resolve import ResourceResolver from adcp.decisioning.state import StateReader from adcp.decisioning.task_registry import TaskRegistry - from adcp.decisioning.trust_grants import TrustGrantGate from adcp.decisioning.types import Account from adcp.reporting.outbox.activity import ReportingActivityProjector from adcp.reporting.outbox.status_support import ReportingStatusSupport @@ -673,16 +672,6 @@ async def _resolve_buyer_agent( budget = PermissionDeniedBudget() credential = auth_info.credential if auth_info is not None else None - if ( - isinstance(credential, HttpSigCredential) - and auth_info is not None - and auth_info.extra.get("signing_profile") == "web-bot-auth" - and auth_info.extra.get("principal_id") is None - ): - # An established origin cannot inherit a legacy registry row before - # explicit binding, even if both identities use exactly the same URL. - # Use the ordinary missing-credential denial and its oracle clamp. - credential = None agent: BuyerAgent | None = None if credential is not None: if isinstance(credential, HttpSigCredential): @@ -1472,7 +1461,6 @@ def __init__( auto_emit_task_webhooks: bool = True, buyer_agent_registry: BuyerAgentRegistry | None = None, brand_authorization_gate: BrandAuthorizationGate | None = None, - trust_grant_gate: TrustGrantGate | None = None, config_store: ProductConfigStore | None = None, property_list_fetcher: PropertyListFetcher | None = None, property_list_filter_mode: Literal["sdk", "platform"] = "sdk", @@ -1517,7 +1505,6 @@ def __init__( self._auto_emit_task_webhooks = auto_emit_task_webhooks self._buyer_agent_registry = buyer_agent_registry self._brand_authorization_gate = brand_authorization_gate - self._trust_grant_gate = trust_grant_gate self._config_store = config_store from adcp.property_lists import MCPPropertyListFetcher @@ -1599,7 +1586,6 @@ async def _resolve_account( ctx: ToolContext, *, tool_name: str | None = None, - request_params: Any = None, ) -> Account[Any]: """Resolve a wire :class:`AccountReference` to a typed :class:`Account` via the platform's :class:`AccountStore`. @@ -1711,24 +1697,7 @@ async def _resolve_account( # an internal mapping, etc.). The gate is a no-op when no buyer # agent has been resolved (Tier 2 not wired) — see # :func:`_enforce_brand_authorization` for the rationale. - if self._trust_grant_gate is not None: - # The trust gate owns source selection, including any explicit - # legacy callback. A second brand.json gate would violate the - # successful-trust-record precedence rule. - await self._trust_grant_gate.require_account( - resolved, - ctx.metadata.get(_BUYER_AGENT_METADATA_KEY), - signing_profile=self._grant_signing_profile(ctx), - operation=tool_name, - bound_wba_identity=self._has_bound_wba_identity(ctx), - operation_params=( - request_params.model_dump(mode="json", exclude_unset=True) - if hasattr(request_params, "model_dump") - else request_params - ), - spend_committing=self._wba_spend_decision(ctx), - ) - elif self._brand_authorization_gate is not None: + if self._brand_authorization_gate is not None: buyer_agent_for_gate = cast( "BuyerAgent | None", ctx.metadata.get(_BUYER_AGENT_METADATA_KEY) if ctx.metadata else None, @@ -1740,33 +1709,6 @@ async def _resolve_account( ) return resolved - @classmethod - def _wba_spend_decision(cls, ctx: ToolContext) -> bool | None: - auth_info = cls._extract_auth_info(ctx) - value = auth_info.extra.get("wba_spend_committing") if auth_info is not None else None - return value if isinstance(value, bool) else None - - @classmethod - def _has_bound_wba_identity(cls, ctx: ToolContext) -> bool: - """Only trusted transport state can preserve an established binding.""" - auth_info = cls._extract_auth_info(ctx) - return bool( - auth_info is not None - and auth_info.extra.get("signing_profile") == "web-bot-auth" - and isinstance(auth_info.extra.get("principal_id"), str) - and auth_info.extra["principal_id"] - ) - - @classmethod - def _grant_signing_profile( - cls, ctx: ToolContext - ) -> Literal["web-bot-auth", "adcp/request-signing/v1"]: - """Read trusted transport metadata, never an account declaration field.""" - auth_info = cls._extract_auth_info(ctx) - if auth_info is not None and auth_info.extra.get("signing_profile") == "web-bot-auth": - return "web-bot-auth" - return "adcp/request-signing/v1" - @staticmethod def _extract_auth_info(ctx: ToolContext) -> AuthInfo | None: """Pull AuthInfo from ToolContext.metadata when present. @@ -2269,7 +2211,7 @@ async def _invoke_compact_lifecycle( """Resolve account context and dispatch one compact 3.2 lifecycle task.""" tool_ctx = context or ToolContext() account = await self._resolve_account( - getattr(params, "account", None), tool_ctx, tool_name=method_name, request_params=params + getattr(params, "account", None), tool_ctx, tool_name=method_name ) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( @@ -2887,7 +2829,7 @@ async def update_media_buy( # type: ignore[override] """ tool_ctx = context or ToolContext() account = await self._resolve_account( - params.account, tool_ctx, tool_name="update_media_buy", request_params=params + params.account, tool_ctx, tool_name="update_media_buy" ) ctx = self._build_ctx(tool_ctx, account) # v1.5: hydrate ctx.recipes from the consumed proposal via the @@ -3197,21 +3139,6 @@ async def sync_accounts( # type: ignore[override] # (``ACCOUNT_NOT_FOUND`` until the account exists, but the # account exists only after this call succeeds). await self._prime_auth_context(tool_ctx) - if self._trust_grant_gate is not None: - for declaration in getattr(params, "accounts", []) or []: - row = ( - declaration.model_dump(mode="json", exclude_none=True) - if hasattr(declaration, "model_dump") - else declaration - ) - if isinstance(row, dict) and row.get("account_id") and "brand" not in row: - await self._resolve_account(row, tool_ctx, tool_name="sync_accounts") - else: - await self._trust_grant_gate.require_declaration( - row, - tool_ctx.metadata.get(_BUYER_AGENT_METADATA_KEY), - signing_profile=self._grant_signing_profile(tool_ctx), - ) resolve_ctx = self._make_resolve_context(tool_ctx, "sync_accounts") if callable(upsert_request): result = _call_with_optional_ctx(upsert_request, params, ctx=resolve_ctx) diff --git a/src/adcp/decisioning/serve.py b/src/adcp/decisioning/serve.py index acf8f4d85..888c4ee0d 100644 --- a/src/adcp/decisioning/serve.py +++ b/src/adcp/decisioning/serve.py @@ -52,7 +52,6 @@ from adcp.decisioning.resolve import ResourceResolver from adcp.decisioning.state import StateReader from adcp.decisioning.task_registry import TaskRegistry - from adcp.decisioning.trust_grants import TrustGrantGate from adcp.reporting.outbox.activity import ReportingActivityProjector from adcp.reporting.outbox.status_support import ReportingStatusSupport from adcp.reporting.outbox.support import ReportingActivitySupport @@ -101,7 +100,6 @@ def create_adcp_server_from_platform( buyer_agent_registry: BuyerAgentRegistry | None = None, brand_authz_resolver: BrandAuthorizationResolver | None = None, brand_identity_resolver: BrandIdentityResolver | None = None, - trust_grant_gate: TrustGrantGate | None = None, config_store: ProductConfigStore | None = None, property_list_fetcher: PropertyListFetcher | None = None, property_list_filter_mode: Literal["sdk", "platform"] = "sdk", @@ -385,8 +383,6 @@ def create_adcp_server_from_platform( # has a brand to check; an extractor without a resolver never has # anything to do) — fail closed at boot with a specific diagnostic # rather than a silent never-fires gate at request time. - if trust_grant_gate is not None and buyer_agent_registry is None: - raise ValueError("trust_grant_gate requires buyer_agent_registry for established identity") if (brand_authz_resolver is None) != (brand_identity_resolver is None): raise ValueError( "brand_authz_resolver and brand_identity_resolver must be wired " @@ -418,7 +414,6 @@ def create_adcp_server_from_platform( auto_emit_task_webhooks=auto_emit_task_webhooks, buyer_agent_registry=buyer_agent_registry, brand_authorization_gate=brand_authorization_gate, - trust_grant_gate=trust_grant_gate, config_store=config_store, property_list_fetcher=property_list_fetcher, property_list_filter_mode=property_list_filter_mode, @@ -518,7 +513,6 @@ def serve( buyer_agent_registry: BuyerAgentRegistry | None = None, brand_authz_resolver: BrandAuthorizationResolver | None = None, brand_identity_resolver: BrandIdentityResolver | None = None, - trust_grant_gate: TrustGrantGate | None = None, config_store: ProductConfigStore | None = None, property_list_fetcher: PropertyListFetcher | None = None, property_list_filter_mode: Literal["sdk", "platform"] = "sdk", @@ -677,7 +671,6 @@ def serve( buyer_agent_registry=buyer_agent_registry, brand_authz_resolver=brand_authz_resolver, brand_identity_resolver=brand_identity_resolver, - trust_grant_gate=trust_grant_gate, config_store=config_store, property_list_fetcher=property_list_fetcher, property_list_filter_mode=property_list_filter_mode, diff --git a/src/adcp/decisioning/trust_grants.py b/src/adcp/decisioning/trust_grants.py deleted file mode 100644 index 36a69f7c3..000000000 --- a/src/adcp/decisioning/trust_grants.py +++ /dev/null @@ -1,368 +0,0 @@ -"""Opt-in demand-grant gate, after commercial identity and before account side effects.""" - -from __future__ import annotations - -import inspect -import threading -from _thread import LockType -from collections import OrderedDict -from collections.abc import Awaitable, Callable, Mapping -from dataclasses import dataclass, field -from typing import Any, Literal - -from adcp.decisioning.registry import BuyerAgent -from adcp.decisioning.types import Account, AdcpError -from adcp.signing.canonical import canonical_origin -from adcp.signing.etld import registrable_domain -from adcp.signing.trust import GrantAuthorization, TrustGrantEvaluator - - -@dataclass(frozen=True) -class TrustGrantClaim: - brand_domain: str - account_operator: str - brand_id: str | None = None - countries: tuple[str, ...] = () - scope: str = "adcp:media_buying" - - -GrantClaimResolver = Callable[ - [Account[Any], BuyerAgent], TrustGrantClaim | Awaitable[TrustGrantClaim] -] -OperationGrantClaimResolver = Callable[ - [Account[Any], BuyerAgent, str], TrustGrantClaim | Awaitable[TrustGrantClaim] -] - -_ClaimKey = tuple[str, str | None, str | None, str, str | None, frozenset[str]] - - -@dataclass -class _DenialHistory: - denials: dict[_ClaimKey, tuple[int, GrantAuthorization]] = field(default_factory=dict) - positives: OrderedDict[_ClaimKey, GrantAuthorization] = field(default_factory=OrderedDict) - claims: dict[_ClaimKey, int] = field(default_factory=dict) - operator_counts: dict[str, int] = field(default_factory=dict) - revision: int = 0 - lock: LockType = field(default_factory=threading.Lock) - - -# R6 assigns media buying to buys and their account provisioning. Other -# domains have no universal operation-to-scope mapping: sellers choose the -# required scope explicitly rather than inheriting media-buying authority. -_MEDIA_BUY_DEFAULT_OPERATIONS = frozenset( - { - "sync_accounts", - "get_products", - "list_products", - "request_proposals", - "refine_proposals", - "decline_proposals", - "buy_products", - "accept_proposal", - "create_media_buy", - "update_media_buy", - "control_media_buy", - "get_media_buys", - "get_media_buy_delivery", - "get_reporting_status", - "sync_reporting_receipts", - "provide_performance_feedback", - "sync_creatives", - "list_creatives", - "list_creative_formats", - } -) - - -def _cached_binding_may_continue( - operation: str | None, - params: Mapping[str, Any] | None, - spend_committing: bool | None, -) -> bool: - """Use the transport's spend decision, with conservative direct-call defaults.""" - if operation is None or operation == "sync_accounts": - return False - if spend_committing is not None: - return not spend_committing - from adcp.server.mcp_tools import ADCP_TOOL_DEFINITIONS - - if not any(tool["name"] == operation for tool in ADCP_TOOL_DEFINITIONS): - return False - if operation in {"update_media_buy", "control_media_buy"} and params is None: - return False - from adcp.server.signed_requests import is_wba_spend_committing - - return not is_wba_spend_committing(operation, params) - - -@dataclass(frozen=True) -class TrustGrantGate: - """Extract existing-account authority from seller-owned state. - - Natural-key sync_accounts declarations are checked directly before upsert. - Existing accounts use extract_claim, so a caller cannot omit the brand to - bypass grant checking. Requires a BuyerAgentRegistry in the composition root. - """ - - evaluator: TrustGrantEvaluator - extract_claim: GrantClaimResolver - extract_operation_claim: OperationGrantClaimResolver | None = None - max_denials: int = 4096 - max_claims_per_operator: int = 128 - _history: _DenialHistory = field( - default_factory=_DenialHistory, init=False, repr=False, compare=False - ) - - def __post_init__(self) -> None: - if ( - type(self.max_denials) is not int - or self.max_denials <= 0 - or type(self.max_claims_per_operator) is not int - or self.max_claims_per_operator <= 0 - ): - raise ValueError("claim admission limits must be positive integers") - - async def require_claim( - self, - claim: TrustGrantClaim, - agent: BuyerAgent | None, - *, - signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - allow_cached_binding_on_outage: bool = False, - ) -> None: - if agent is None: - await self._require_admitted_claim( - claim, - agent, - signing_profile=signing_profile, - allow_cached_binding_on_outage=allow_cached_binding_on_outage, - ) - return - operator = registrable_domain(agent.agent_url) - if operator is None: - raise AdcpError( - "PERMISSION_DENIED", message="Buyer agent is not authorized", recovery="correctable" - ) - key: _ClaimKey = ( - canonical_origin(agent.agent_url), - registrable_domain(claim.brand_domain), - registrable_domain(claim.account_operator), - claim.scope, - claim.brand_id, - frozenset(claim.countries), - ) - with self._history.lock: - if key not in self._history.claims: - if ( - len(self._history.claims) >= self.max_denials - or self._history.operator_counts.get(operator, 0) - >= self.max_claims_per_operator - ): - raise AdcpError( - "SERVICE_UNAVAILABLE", - message="Claim history admission capacity exceeded", - recovery="transient", - ) - self._history.claims[key] = 0 - self._history.operator_counts[operator] = ( - self._history.operator_counts.get(operator, 0) + 1 - ) - self._history.claims[key] += 1 - try: - await self._require_admitted_claim( - claim, - agent, - signing_profile=signing_profile, - allow_cached_binding_on_outage=allow_cached_binding_on_outage, - ) - finally: - with self._history.lock: - self._history.claims[key] -= 1 - if ( - self._history.claims[key] == 0 - and key not in self._history.positives - and key not in self._history.denials - ): - del self._history.claims[key] - self._history.operator_counts[operator] -= 1 - if not self._history.operator_counts[operator]: - del self._history.operator_counts[operator] - - async def _require_admitted_claim( - self, - claim: TrustGrantClaim, - agent: BuyerAgent | None, - *, - signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - allow_cached_binding_on_outage: bool = False, - ) -> None: - if agent is None: - # No established commercial identity: omit details under the oracle clamp. - raise AdcpError( - "PERMISSION_DENIED", message="Buyer agent is not authorized", recovery="correctable" - ) - key: _ClaimKey = ( - canonical_origin(agent.agent_url), - registrable_domain(claim.brand_domain), - registrable_domain(claim.account_operator), - claim.scope, - claim.brand_id, - frozenset(claim.countries), - ) - with self._history.lock: - started_revision = self._history.revision - result = await self.evaluator.check( - agent_origin=agent.agent_url, - brand_domain=claim.brand_domain, - account_operator=claim.account_operator, - brand_id=claim.brand_id, - countries=claim.countries, - scope=claim.scope, - signing_profile=signing_profile, - ) - continuity_deadline: float | None = None - if result.error_code == "SERVICE_UNAVAILABLE" and allow_cached_binding_on_outage: - with self._history.lock: - positive = self._history.positives.get(key) - if ( - key not in self._history.denials - and positive is not None - and positive.outage_expires_at is not None - and positive.outage_expires_at > self.evaluator.current_time() - ): - continuity_deadline = positive.outage_expires_at - if continuity_deadline is not None: - # A positive claim alone is insufficient: another request may - # have read a newer owner withdrawal or delisting. Revalidate - # every link against the latest retained record snapshots. - result = await self.evaluator.check( - agent_origin=agent.agent_url, - brand_domain=claim.brand_domain, - account_operator=claim.account_operator, - brand_id=claim.brand_id, - countries=claim.countries, - scope=claim.scope, - signing_profile=signing_profile, - allow_stale_on_outage=True, - ) - with self._history.lock: - denied = self._history.denials.get(key) - if result.authorized: - if denied is not None and denied[0] > started_revision: - # An older in-flight approval cannot undo a newer denial. - result = denied[1] - elif continuity_deadline is not None: - if ( - result.outage_expires_at is None - or min(continuity_deadline, result.outage_expires_at) - <= self.evaluator.current_time() - ): - result = GrantAuthorization( - False, - error_code="SERVICE_UNAVAILABLE", - include_grant_absent_details=False, - ) - # An outage read must never refresh the retained proof. - elif ( - result.expires_at is not None - and result.expires_at <= self.evaluator.current_time() - ): - result = GrantAuthorization( - False, error_code="SERVICE_UNAVAILABLE", include_grant_absent_details=False - ) - else: - self._history.denials.pop(key, None) - if result.outage_expires_at is not None: - self._history.positives[key] = result - self._history.positives.move_to_end(key) - elif result.error_code == "PERMISSION_DENIED": - self._history.positives.pop(key, None) - self._history.revision += 1 - self._history.denials[key] = (self._history.revision, result) - elif result.error_code == "SERVICE_UNAVAILABLE": - if denied is not None: - result = denied[1] - result.require() - - async def require_account( - self, - account: Account[Any], - agent: BuyerAgent | None, - *, - signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - operation: str | None = None, - bound_wba_identity: bool = False, - operation_params: Mapping[str, Any] | None = None, - spend_committing: bool | None = None, - ) -> None: - if agent is None: - await self.require_claim(TrustGrantClaim("", ""), None) - return - claim = ( - self.extract_operation_claim(account, agent, operation) - if self.extract_operation_claim is not None and operation is not None - else self.extract_claim(account, agent) - ) - if inspect.isawaitable(claim): - claim = await claim - if ( - operation is not None - and operation not in _MEDIA_BUY_DEFAULT_OPERATIONS - and claim.scope == "adcp:media_buying" - ): - raise AdcpError( - "PERMISSION_DENIED", - message="Operation requires an explicit grant scope", - details={"scope": "agent", "reason": "grant_absent"}, - recovery="correctable", - ) - await self.require_claim( - claim, - agent, - signing_profile=signing_profile, - allow_cached_binding_on_outage=( - bound_wba_identity - and signing_profile == "web-bot-auth" - and _cached_binding_may_continue(operation, operation_params, spend_committing) - ), - ) - - async def require_declaration( - self, - declaration: Mapping[str, Any], - agent: BuyerAgent | None, - *, - signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - ) -> None: - brand, operator = declaration.get("brand"), declaration.get("operator") - if ( - not isinstance(brand, Mapping) - or not isinstance(brand.get("domain"), str) - or not isinstance(operator, str) - ): - if agent is None: - await self.require_claim(TrustGrantClaim("", ""), None) - raise AdcpError( - "PERMISSION_DENIED", - message="Account declaration has no grant authority", - details={"scope": "agent", "reason": "grant_absent"}, - recovery="correctable", - ) - await self.require_claim( - TrustGrantClaim( - brand["domain"], - operator, - brand.get("brand_id"), - tuple(brand.get("countries") or ()), - ), - agent, - signing_profile=signing_profile, - ) - - -__all__ = [ - "GrantClaimResolver", - "OperationGrantClaimResolver", - "TrustGrantClaim", - "TrustGrantGate", -] diff --git a/src/adcp/notification_outbox_pg.py b/src/adcp/notification_outbox_pg.py index 03c5b2f2d..9a2699c19 100644 --- a/src/adcp/notification_outbox_pg.py +++ b/src/adcp/notification_outbox_pg.py @@ -114,7 +114,6 @@ def __init__( table: str = DEFAULT_TABLE, encryption_key_id: str = "default", decryption_keys: Mapping[str, bytes] | None = None, - experimental_agent_identity: bool = False, ) -> None: try: import psycopg_pool # noqa: F401 @@ -153,7 +152,6 @@ def __init__( self._pool = pool self._sender = sender self._sender_resolver = sender_resolver - self._experimental_agent_identity = experimental_agent_identity self.delivery_retry_horizon_seconds = delivery_retry_horizon_seconds self._lease_seconds = lease_seconds if sender is not None: @@ -361,7 +359,6 @@ async def enqueue_prepared( idempotency_key=prepared.idempotency_key, notification_type=notification_type, account_id=account_id, - experimental_agent_identity=self._experimental_agent_identity, ) if payload.get("account_id") is not None and account_id is None: raise ValueError("account-anchored notifications require account_id") @@ -449,11 +446,7 @@ def _open(self, row: tuple[Any, ...]) -> PreparedWebhook: ): raise ValueError("invalid stored notification headers") validate_notification_body( - body, - idempotency_key=key, - notification_type=kind, - account_id=account, - experimental_agent_identity=self._experimental_agent_identity, + body, idempotency_key=key, notification_type=kind, account_id=account ) return PreparedWebhook(url=url, idempotency_key=key, body=body, extra_headers=headers) @@ -534,12 +527,6 @@ async def process_one(self) -> bool: async def attempt() -> WebhookDeliveryResult: sender = await self._resolve_sender(row[6]) - if self._experimental_agent_identity: - return await sender.send_prepared( - prepared, - before_attempt=lambda: self._lease_active(row_id, token), - experimental_agent_identity=True, - ) return await sender.send_prepared( prepared, before_attempt=lambda: self._lease_active(row_id, token) ) diff --git a/src/adcp/protocols/base.py b/src/adcp/protocols/base.py index 60f41e3ba..9800364ec 100644 --- a/src/adcp/protocols/base.py +++ b/src/adcp/protocols/base.py @@ -131,7 +131,6 @@ def _parse_response( message=raw_result.message, success=raw_result.success, # Preserve original success state error=raw_result.error, # Only use error if one was set - adcp_error=raw_result.adcp_error, metadata=raw_result.metadata, debug_info=raw_result.debug_info, idempotency_key=raw_result.idempotency_key, diff --git a/src/adcp/server/_size_limit.py b/src/adcp/server/_size_limit.py index afe571472..47a64ed34 100644 --- a/src/adcp/server/_size_limit.py +++ b/src/adcp/server/_size_limit.py @@ -45,14 +45,12 @@ DEFAULT_MAX_REQUEST_BYTES: int = 10 * 1024 * 1024 -def make_replay_receive( - chunks: Sequence[bytes], *, receive: Callable[[], Any] | None = None -) -> Callable[[], Any]: +def make_replay_receive(chunks: Sequence[bytes]) -> Callable[[], Any]: """Return an ASGI ``receive`` callable that replays buffered body chunks. - After the buffered body, forward to ``receive`` when supplied so a - streaming response observes the client's real disconnect. Standalone - callers without a receive channel retain the immediate disconnect. + Once the body is drained, subsequent reads return ``http.disconnect``. + This is important for streaming applications, which may keep polling the + receive channel while producing a response. """ index = 0 chunks_count = len(chunks) @@ -67,7 +65,7 @@ async def replay_receive() -> dict[str, Any]: "body": body, "more_body": index < chunks_count, } - return await receive() if receive is not None else {"type": "http.disconnect"} + return {"type": "http.disconnect"} return replay_receive @@ -166,7 +164,7 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None more_body = bool(msg.get("more_body", False)) # Body fit within the cap — replay to the app. - await self.app(scope, make_replay_receive(chunks, receive=receive), send) + await self.app(scope, make_replay_receive(chunks), send) @staticmethod async def _send_413(send: Any, max_bytes: int) -> None: diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index ca2305333..4b837d690 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -32,8 +32,7 @@ from a2a.server.agent_execution.context import RequestContext from a2a.server.context import ServerCallContext from a2a.server.events.event_queue import EventQueue -from a2a.server.request_handlers import DefaultRequestHandler, validate_request_params -from a2a.server.request_handlers.request_handler import validate +from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore from google.protobuf.json_format import MessageToDict, ParseDict @@ -49,8 +48,6 @@ if TYPE_CHECKING: from collections.abc import Sequence - from a2a.server.agent_execution.active_task import ActiveTask - from a2a.server.events.event_queue import Event from a2a.server.request_handlers import RequestHandler from a2a.server.tasks.push_notification_config_store import ( PushNotificationConfigStore, @@ -62,7 +59,7 @@ from adcp.server.auth import BearerTokenAuth from adcp.server.serve import ContextFactory, SkillMiddleware -from collections.abc import AsyncGenerator, Awaitable, Callable # noqa: E402 +from collections.abc import Awaitable, Callable # noqa: E402 from adcp.validation.client_hooks import ( # noqa: E402 SERVER_DEFAULT_VALIDATION, @@ -160,67 +157,6 @@ def _get_decisioning_adcp_error_types() -> tuple[type[BaseException], ...]: _A2A_PARSED_REQUEST_SCOPE_KEY = "adcp.a2a_parsed_request" -class _OwnerCheckedRequestHandler(DefaultRequestHandler): - """Check persisted ownership before the SDK's globally indexed active tasks.""" - - async def _owned_active_task(self, task_id: str, context: ServerCallContext) -> ActiveTask: - from a2a.utils.errors import TaskNotFoundError - - if await self.task_store.get(task_id, context) is None: - raise TaskNotFoundError - active: ActiveTask = await self._active_task_registry.get_or_create( - task_id, call_context=context, create_task_if_missing=False - ) - # Check the exact object used below, after its last registry lookup. - # Cleanup can replace a global ID between awaits, and cancel replaces - # the active task's save context. Neither may transfer foreign work. - if not self._task_owner_matches(active, context): - raise TaskNotFoundError - return active - - @staticmethod - def _task_owner_matches(active: ActiveTask, context: ServerCallContext) -> bool: - owner = active._task_manager._call_context - return owner.user.user_name == context.user.user_name and owner.tenant == context.tenant - - async def _setup_active_task( - self, params: pb.SendMessageRequest, call_context: ServerCallContext - ) -> tuple[ActiveTask, RequestContext]: - from a2a.utils.errors import TaskNotFoundError - - active, request = await super()._setup_active_task(params, call_context) - # An existing same-ID row in the caller's partition cannot retarget - # the foreign active task, including its persistence/owner context. - if not self._task_owner_matches(active, call_context): - raise TaskNotFoundError - return active, request - - @validate_request_params - async def on_cancel_task( - self, params: pb.CancelTaskRequest, context: ServerCallContext - ) -> pb.Task | None: - from a2a.utils.errors import InvalidParamsError, TaskNotCancelableError - - try: - active = await self._owned_active_task(params.id, context) - result = await active.cancel(context) - except InvalidParamsError as exc: - raise TaskNotCancelableError from exc - return result - - @validate_request_params - @validate( - lambda self: self._agent_card.capabilities.streaming, - "Streaming is not supported by the agent", - ) - async def on_subscribe_to_task( - self, params: pb.SubscribeToTaskRequest, context: ServerCallContext - ) -> AsyncGenerator[Event, None]: - active = await self._owned_active_task(params.id, context) - async for event in active.subscribe(include_initial_task=True): - yield event - - class _A2ARequestContextMiddleware: """Make the originating HTTP request available during A2A dispatch.""" @@ -691,10 +627,10 @@ def _build_tool_context(self, skill_name: str, request: RequestContext) -> ToolC async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: """ADCP operations are synchronous; cancellation sets state to canceled.""" - event = pb.TaskStatusUpdateEvent( - task_id=context.task_id or str(uuid4()), - context_id=context.context_id or str(uuid4()), - status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_CANCELED), + event = _make_task( + context, + state=pb.TaskState.TASK_STATE_CANCELED, + message="Task canceled", ) await event_queue.enqueue_event(event) @@ -1566,7 +1502,7 @@ def agent_card_url(request: Request) -> str: # DefaultRequestHandler stores push_config_store verbatim and # treats None as "push-notif unsupported". Passing None is the # correct default; sellers opt in by wiring a store. - _rpc_handler: RequestHandler = _OwnerCheckedRequestHandler( + _rpc_handler: RequestHandler = DefaultRequestHandler( agent_executor=executor, task_store=task_store, agent_card=fallback_card, @@ -1616,7 +1552,7 @@ def agent_card_url(request: Request) -> str: # None as "push-notif endpoints unsupported" (UnsupportedOperationError # on tasks/pushNotificationConfig/*). Passing None is the correct # default; sellers opt in by wiring a store. - _rpc_handler = _OwnerCheckedRequestHandler( + _rpc_handler = DefaultRequestHandler( agent_executor=executor, task_store=task_store, agent_card=agent_card, diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index 4f2e89209..a13a5c0db 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -89,12 +89,7 @@ async def validate_token(token: str) -> Principal | None: from adcp.server.base import ToolContext from adcp.server.mcp_tools import DISCOVERY_METHODS, DISCOVERY_TOOLS -from adcp.server.signed_requests import ( - scope_has_verified_signer, - scope_has_wba_error_projection, - scope_has_wba_prior_task_control, - signature_fallback_code, -) +from adcp.server.signed_requests import scope_has_verified_signer, signature_fallback_code from adcp.signing.errors import signature_challenge logger = logging.getLogger("adcp.server.auth") @@ -641,14 +636,6 @@ async def dispatch(self, request: Request, call_next: Any) -> Any: tenant_token = None metadata_token = None try: - if scope_has_wba_error_projection(request.scope) or scope_has_wba_prior_task_control( - request.scope - ): - principal_token = current_principal.set(None) - tenant_token = current_tenant.set(None) - metadata_token = current_principal_metadata.set(None) - _set_request_state(request, None, None, None) - return await call_next(request) credential = _resolve_bearer_headers( request.scope.get("headers", ()), self._alias_header_names, @@ -1745,23 +1732,6 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: await self._app(scope, receive, send) return - if scope_has_wba_error_projection(scope) or scope_has_wba_prior_task_control(scope): - from starlette.requests import Request - - # Error-only dispatch or the delisted origin's old task owner - # cannot establish a commercial principal or use bearer fallback. - principal_token = current_principal.set(None) - tenant_token = current_tenant.set(None) - metadata_token = current_principal_metadata.set(None) - _set_request_state(Request(scope), None, None, None) - try: - await self._app(scope, receive, send) - finally: - current_principal.reset(principal_token) - current_tenant.reset(tenant_token) - current_principal_metadata.reset(metadata_token) - return - signature_error = signature_fallback_code(scope) credential = self._resolve_credential(scope) principal: Principal | None = None diff --git a/src/adcp/server/principal.py b/src/adcp/server/principal.py index 840499ca6..0f3f132f2 100644 --- a/src/adcp/server/principal.py +++ b/src/adcp/server/principal.py @@ -323,15 +323,11 @@ def __init__( accepted_async_adcp_versions: tuple[str, ...] = ("3.2",), accepted_webhook_signing_algorithms: tuple[str, ...] = (), accepted_experimental_features: tuple[str, ...] = (), - relationship_resolver: ( - Callable[[PrincipalIdentity], Awaitable[Mapping[str, Any] | BaseModel | None]] | None - ) = None, ) -> None: self._store = store or InMemoryPrincipalRecordStore() self._destination_proof = destination_proof self._notification_proof = notification_proof self._emit_change = emit_change - self._relationship_resolver = relationship_resolver self._accepted_declarations = { "async_adcp_versions": accepted_async_adcp_versions, "webhook_signing_algorithms": accepted_webhook_signing_algorithms, @@ -371,28 +367,6 @@ async def get_principal( self, identity: PrincipalIdentity, request: GetPrincipalRequest | Mapping[str, Any] | None = None, - ) -> GetPrincipalResponse: - """Read state and optional experimental relationship for this authenticated caller.""" - response = await self._get_principal_state(identity, request) - if self._relationship_resolver is None or response.result.kind == "failed": - return response - relationship = await self._relationship_resolver(identity) - if relationship is None: - return response - from adcp.types.experimental_identity import GetPrincipalResponse as PreviewResponse - - envelope = response.model_dump(mode="json", exclude_none=True) - envelope["result"]["relationship"] = ( - relationship.model_dump(mode="json", exclude_none=True) - if isinstance(relationship, BaseModel) - else dict(relationship) - ) - return PreviewResponse.model_validate(envelope) - - async def _get_principal_state( - self, - identity: PrincipalIdentity, - request: GetPrincipalRequest | Mapping[str, Any] | None = None, ) -> GetPrincipalResponse: request = GetPrincipalRequest.model_validate(request or {}) record = await self._store.get(identity.subject) diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index fa4048d15..ad879d0ff 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -58,7 +58,6 @@ async def get_adcp_capabilities(self, params, context=None): from adcp.server.signed_requests import ( apply_verified_signer, check_request_signature_verification, - check_wba_authorization, wrap_with_signature_verification, ) from adcp.validation.client_hooks import ( @@ -848,7 +847,6 @@ async def _dispatch_with_middleware( exception-observation behaviors are owned by the transport-level executor, not the composer. """ - check_wba_authorization(context) if not middleware: return await call_handler() @@ -2205,7 +2203,6 @@ def _build_a2a_app( transport="a2a", message_parser=message_parser, bearer_configured=auth is not None, - defer_authorization_errors=request_handler is None, ) if include_discovery: app = _wrap_with_discovery( @@ -2473,7 +2470,6 @@ def _build_mcp_and_a2a_app( transport="a2a", message_parser=message_parser, bearer_configured=auth is not None, - defer_authorization_errors=request_handler is None, ) # Lifespan composition: FastMCP's session manager initializes a @@ -3384,7 +3380,6 @@ async def _call_handler() -> Any: frozen_reporting_feed=frozen_reporting_feed, ) else: - check_wba_authorization(context) result = await _call_handler() except ADCPError as exc: # AdCP-typed exceptions (IdempotencyConflictError, ADCPTaskError diff --git a/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index da3d3c9d9..1ed79e9d8 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -37,11 +37,10 @@ import logging import os import time -import warnings -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass, field, replace from functools import partial -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal import anyio import anyio.from_thread @@ -50,7 +49,6 @@ from starlette.responses import JSONResponse from adcp.server._size_limit import make_replay_receive -from adcp.signing.bindings import OriginBindingResolver from adcp.signing.errors import ( REQUEST_SIGNATURE_HEADER_MALFORMED, REQUEST_SIGNATURE_JWKS_UNAVAILABLE, @@ -60,27 +58,16 @@ from adcp.signing.middleware import VERIFIED_SIGNER_SCOPE_KEY, unauthorized_response_headers from adcp.signing.replay import InMemoryReplayStore, ReplayStore from adcp.signing.revocation import RevocationChecker -from adcp.signing.signer_keys import WbaSignerKeyResolver from adcp.signing.verifier import ( VerifiedSigner, VerifierCapability, VerifyOptions, verify_request_signature, ) -from adcp.signing.wba import ( - OriginRevocationChecker, - WbaAuthorizationError, - WbaUnavailableError, - WbaVerificationError, - uses_web_bot_auth, - verify_wba_request, -) if TYPE_CHECKING: - from adcp.decisioning.types import AdcpError from adcp.server.base import ToolContext from adcp.signing.signer_keys import ResolvedSignerKey, SignerKeyResolver - from adcp.signing.trust import TrustRecordEvaluator from adcp.signing.verifier import CoversDigestPolicy, SigningProfileVersion logger = logging.getLogger(__name__) @@ -91,86 +78,6 @@ current_verified_signer: contextvars.ContextVar[VerifiedSigner | None] = contextvars.ContextVar( "adcp_current_verified_signer", default=None ) -current_wba_authorization_error: contextvars.ContextVar[AdcpError | None] = contextvars.ContextVar( - "adcp_wba_authorization_error", default=None -) -_WBA_AUTHORIZATION_ERROR_KEY = "adcp.server.wba_authorization_error" -_WBA_ERROR_PROJECTION_ONLY_KEY = "adcp.server.wba_error_projection_only" -_WBA_SPEND_COMMITTING_KEY = "adcp.wba_spend_committing" -_WBA_PRIOR_TASK_CONTROL_KEY = "adcp.server.wba_prior_task_control" -_PRIOR_TASK_CONTROL_METHODS = frozenset( - {"tasks/get", "tasks/cancel", "tasks/resubscribe", "GetTask", "CancelTask", "SubscribeToTask"} -) - - -@dataclass -class _AuthorizationDecision: - """Carry the request's evaluated permission without another authority read.""" - - prior_task_control: bool = False - - -current_wba_spend_committing: contextvars.ContextVar[bool | None] = contextvars.ContextVar( - "adcp_wba_spend_committing", default=None -) -current_wba_error_projection_only: contextvars.ContextVar[bool] = contextvars.ContextVar( - "adcp_wba_error_projection_only", default=False -) - - -def is_wba_spend_committing(operation: str, params: Mapping[str, Any] | None = None) -> bool: - """Classify new spend; sellers can override ambiguous budget changes with state.""" - if operation in {"create_media_buy", "buy_products", "accept_proposal"} or operation.startswith( - "acquire_" - ): - return True - if operation not in {"update_media_buy", "control_media_buy"}: - from adcp.server.mcp_tools import ADCP_TOOL_DEFINITIONS - - protocol_reads = { - "initialize", - "tools/list", - "ping", - "tasks/get", - "tasks/list", - "tasks/cancel", - "tasks/resubscribe", - "GetTask", - "ListTasks", - "CancelTask", - "SubscribeToTask", - "notifications/initialized", - "notifications/cancelled", - } - return operation not in protocol_reads and not any( - tool["name"] == operation for tool in ADCP_TOOL_DEFINITIONS - ) - if params is None: - return True - params = params or {} - if params.get("paused") is False or params.get("new_packages"): - return True - financial = {"budget", "total_budget", "daily_budget_cap", "budget_allocation", "bidding"} - if financial.intersection(params): - return True - packages = params.get("packages") - if not isinstance(packages, list): - return False - return any( - isinstance(package, Mapping) - and (package.get("paused") is False or bool(financial.intersection(package))) - for package in packages - ) - - -def check_wba_authorization(context: ToolContext | None) -> None: - """Raise deferred identity authorization through the transport's AdCP error binding.""" - from adcp.decisioning.types import AdcpError - - error = context.metadata.get(_WBA_AUTHORIZATION_ERROR_KEY) if context is not None else None - if isinstance(error, AdcpError): - raise error - SignaturePosture = Literal["required", "warn", "supported"] @@ -225,18 +132,7 @@ class RequestSignatureVerification: :param replay_store: Nonce store shared by every request. The default is one per-process :class:`~adcp.signing.InMemoryReplayStore`, which does not protect a multi-replica deployment; wire a shared store - (e.g. ``PgReplayStore``) there. ``None`` disables replay checks for - the legacy profile and is rejected when Web Bot Auth is enabled. - :param wba_revocation_checker: Async origin/key revocation checker. When - Web Bot Auth is enabled, defaults to ``OriginRevocationChecker`` - using the configured signer keys; revocation fetch failures reject - the signed request. - :param trust_records: Operator membership evaluator used when authorizing - an existing WBA binding. Defaults to ``TrustRecordEvaluator``. Its - cache bounds binding freshness; it never participates in key discovery. - :param unbound_for: Operations available to an identity-established caller - before approval. Match the application's anonymous permissions; add - ``sync_principal`` only when its handler implements an open policy. + (e.g. ``PgReplayStore``) there. ``None`` disables replay checks. """ signer_keys: SignerKeyResolver @@ -250,40 +146,8 @@ class RequestSignatureVerification: replay_store: ReplayStore | None = field(default_factory=InMemoryReplayStore) revocation_checker: RevocationChecker | None = None signing_profile_version: SigningProfileVersion = "3.1" - enable_web_bot_auth: bool = False - origin_bindings: OriginBindingResolver | None = None - wba_revocation_checker: Any = None - trust_records: TrustRecordEvaluator | None = None - binding_role: str = "adcp:buying" - unbound_for: frozenset[str] = frozenset({"get_adcp_capabilities", "get_principal"}) - spend_classifier: Callable[[str, Mapping[str, Any]], bool] = is_wba_spend_committing - _binding_membership: dict[str, bool] = field( - default_factory=dict, init=False, repr=False, compare=False - ) - _delisted_task_origins: set[str] = field( - default_factory=set, init=False, repr=False, compare=False - ) def __post_init__(self) -> None: - if self.enable_web_bot_auth and self.replay_store is None: - raise ValueError("Web Bot Auth requires a replay store") - if self.enable_web_bot_auth and self.wba_revocation_checker is None: - object.__setattr__( - self, - "wba_revocation_checker", - OriginRevocationChecker(cast(WbaSignerKeyResolver, self.signer_keys)), - ) - if self.enable_web_bot_auth and self.trust_records is None: - from adcp.signing.trust import TrustRecordEvaluator - - object.__setattr__(self, "trust_records", TrustRecordEvaluator()) - if not self.enable_web_bot_auth: - warnings.warn( - "RFC 9421-profile verification is deprecated in AdCP 3.3 and remains " - "supported through 3.x; enable_web_bot_auth opts into the experimental profile.", - DeprecationWarning, - stacklevel=2, - ) overlap = self.required_for & self.warn_for if overlap: raise ValueError( @@ -446,18 +310,13 @@ def __init__( transport: Literal["mcp", "a2a"], message_parser: Any = None, bearer_configured: bool = False, - defer_authorization_errors: bool = False, ) -> None: self._app = app self._config = config self._transport = transport self._message_parser = message_parser - self._parse_skill = bool( - config.required_for or config.warn_for or config.enable_web_bot_auth - ) + self._parse_skill = bool(config.required_for or config.warn_for) self._bearer_configured = bearer_configured - self._defer_authorization_errors = defer_authorization_errors - self._binding_membership = config._binding_membership async def __call__(self, scope: Any, receive: Any, send: Any) -> None: # Only JSON-RPC POSTs carry an operation; GET (SSE, agent card), @@ -474,77 +333,6 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: # the framework's resolver never vouches for a key it did not # check. if existing.agent_url is not None: - from adcp.signing.canonical import canonical_origin - - origin = canonical_origin(existing.agent_url) - if existing.profile == "web-bot-auth": - decision = _AuthorizationDecision() - try: - if not self._config.enable_web_bot_auth: - raise WbaVerificationError("Web Bot Auth is not enabled") - try: - revoked = await self._config.wba_revocation_checker( - existing.agent_url, existing.key_id - ) - except WbaVerificationError: - raise - except Exception as error: - raise WbaUnavailableError( - "origin revocation status unavailable" - ) from error - if revoked: - raise WbaAuthorizationError("signing key revoked") - target = _jsonrpc_target( - json.dumps(existing.parsed_body).encode(), - self._transport, - self._message_parser, - parse_skill=True, - ) - existing, authorization_error = await self._authorize_wba_signer( - existing, target.operation or target.method or "", decision=decision - ) - except WbaVerificationError as exc: - if isinstance(exc, (WbaAuthorizationError, WbaUnavailableError)): - await self._project_wba_authentication_error( - scope, - receive, - send, - json.dumps(existing.parsed_body).encode(), - exc, - ) - return - await self._reject_wba(scope, receive, send, exc) - return - await self._call_with_signer( - scope, - receive, - send, - existing, - authorization_error=authorization_error, - prior_task_control=decision.prior_task_control, - ) - return - principal = ( - await self._config.origin_bindings(origin) - if self._config.origin_bindings is not None - else None - ) - if principal is not None: - from adcp.decisioning.types import AdcpError - - await self._call_with_signer( - scope, - receive, - send, - replace(existing, principal_id=None), - authorization_error=AdcpError( - "PERMISSION_DENIED", - message="Bound origin requires Web Bot Auth", - recovery="correctable", - ), - ) - return - existing = replace(existing, principal_id=principal) await self._call_with_signer(scope, receive, send, existing) else: await self._app(scope, receive, send) @@ -561,7 +349,7 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: chunks.append(message.get("body", b"")) more_body = bool(message.get("more_body", False)) body = b"".join(chunks) - replay = make_replay_receive(chunks, receive=receive) + replay = make_replay_receive(chunks) target = _jsonrpc_target( body, self._transport, self._message_parser, parse_skill=self._parse_skill @@ -575,8 +363,7 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: signature_input = _header(scope, b"signature-input") signature = _header(scope, b"signature") - wba = uses_web_bot_auth(dict(Request(scope).headers)) - if signature_input is None and signature is None and not wba: + if signature_input is None and signature is None: if posture == "required": if self._config.allow_bearer_fallback and self._bearer_configured: scope[_SIGNATURE_FALLBACK_SCOPE_KEY] = REQUEST_SIGNATURE_REQUIRED @@ -599,17 +386,8 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: await self._app(scope, replay, send) return - decision = _AuthorizationDecision() try: - signer, authorization_error = await self._verify( - scope, body, label, posture, decision=decision - ) - except WbaVerificationError as exc: - if isinstance(exc, (WbaAuthorizationError, WbaUnavailableError)): - await self._project_wba_authentication_error(scope, replay, send, body, exc) - return - await self._reject_wba(scope, replay, send, exc) - return + signer = await self._verify(scope, body, label, posture) except SignatureVerificationError as exc: if posture == "warn" and exc.code != REQUEST_SIGNATURE_HEADER_MALFORMED: logger.warning( @@ -620,14 +398,7 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: return await self._reject(send, exc) return - await self._call_with_signer( - scope, - replay, - send, - signer, - authorization_error=authorization_error, - prior_task_control=decision.prior_task_control, - ) + await self._call_with_signer(scope, replay, send, signer) async def _verify( self, @@ -635,9 +406,7 @@ async def _verify( body: bytes, label: str, posture: SignaturePosture | None, - *, - decision: _AuthorizationDecision | None = None, - ) -> tuple[VerifiedSigner, AdcpError | None]: + ) -> VerifiedSigner: resolved: dict[str, ResolvedSignerKey] = {} # The verifier looks the key up at its own checklist step 7, after @@ -654,24 +423,6 @@ def jwks_resolver(keyid: str) -> dict[str, Any] | None: config = self._config request = Request(scope) - if uses_web_bot_auth(dict(request.headers)): - if not config.enable_web_bot_auth: - raise WbaVerificationError("Web Bot Auth is not enabled") - assert config.replay_store is not None - signers = await verify_wba_request( - method=request.method, - url=str(request.url), - headers=dict(request.headers), - body=body, - signer_keys=cast(WbaSignerKeyResolver, config.signer_keys), - replay_store=config.replay_store, - raw_headers=scope.get("headers"), - revocation_checker=config.wba_revocation_checker, - ) - # The originating signer owns the AdCP caller; relays are verified - # independently and do not acquire the originator's account access. - signer = signers[0] - return await self._authorize_wba_signer(signer, label, decision=decision) options = VerifyOptions( now=time.time(), capability=VerifierCapability( @@ -698,219 +449,7 @@ def jwks_resolver(keyid: str) -> dict[str, Any] | None: ) ) key = resolved.get(signer.key_id) - if key is not None and config.origin_bindings is not None: - from adcp.signing.canonical import canonical_origin - - if await config.origin_bindings(canonical_origin(key.agent_url)) is not None: - from adcp.decisioning.types import AdcpError - - return replace(signer, agent_url=key.agent_url), AdcpError( - "PERMISSION_DENIED", - message="Bound origin requires Web Bot Auth", - recovery="correctable", - ) - return replace(signer, agent_url=key.agent_url if key is not None else None), None - - async def _authorize_wba_signer( - self, - signer: VerifiedSigner, - operation: str, - *, - decision: _AuthorizationDecision | None = None, - ) -> tuple[VerifiedSigner, AdcpError | None]: - """Re-evaluate operator membership at the binding authorization gate.""" - from adcp.decisioning.types import AdcpError - from adcp.signing.canonical import canonical_origin - from adcp.signing.trust import ( - TrustRecordError, - TrustRecordMatchError, - TrustRecordNotFoundError, - TrustRecordUnavailableError, - ) - - config = self._config - if signer.agent_url is not None: - signer = replace(signer, agent_url=canonical_origin(signer.agent_url)) - principal = ( - await config.origin_bindings(signer.agent_url or "") - if config.origin_bindings is not None - else None - ) - if principal is None: - return self._identity_established(signer, operation, decision=decision) - if config.trust_records is None: - return replace(signer, principal_id=principal), AdcpError( - "SERVICE_UNAVAILABLE", - message="Origin binding evaluation unavailable", - recovery="transient", - ) - try: - record = await config.trust_records.evaluate( - signer.agent_url or "", - role=config.binding_role, - allow_stale_on_outage=not self._is_spend_committing(signer, operation), - ) - await config.trust_records.validate_snapshots( - (record,), origin_or_domain=signer.agent_url or "", role=config.binding_role - ) - except (TrustRecordMatchError, TrustRecordNotFoundError): - self._binding_membership[signer.agent_url or ""] = False - config._delisted_task_origins.add(signer.agent_url or "") - return self._identity_established( - signer, operation, prior_task_control=True, decision=decision - ) - except TrustRecordUnavailableError: - if not self._binding_membership.get(signer.agent_url or "", True): - return self._identity_established( - signer, - operation, - prior_task_control=signer.agent_url in config._delisted_task_origins, - decision=decision, - ) - return replace(signer, principal_id=principal), AdcpError( - "SERVICE_UNAVAILABLE", - message="Operator trust record is temporarily unavailable", - recovery="transient", - ) - except TrustRecordError as exc: - self._binding_membership[signer.agent_url or ""] = False - config._delisted_task_origins.discard(signer.agent_url or "") - logger.info("operator trust record rejected", extra={"reason": type(exc).__name__}) - return replace(signer, principal_id=None), AdcpError( - "PERMISSION_DENIED", - message="Signer origin is not authorized by its operator", - recovery="correctable", - ) - self._binding_membership[signer.agent_url or ""] = True - config._delisted_task_origins.discard(signer.agent_url or "") - # Preserve the historical binding when membership ends, so delisting - # cannot enable the deprecated transport as a downgrade. - return replace(signer, principal_id=principal), None - - def _identity_established( - self, - signer: VerifiedSigner, - operation: str, - *, - prior_task_control: bool = False, - decision: _AuthorizationDecision | None = None, - ) -> tuple[VerifiedSigner, AdcpError | None]: - from adcp.decisioning.types import AdcpError - - if decision is not None: - decision.prior_task_control = ( - prior_task_control and operation in _PRIOR_TASK_CONTROL_METHODS - ) - error = ( - None - if operation in self._config.unbound_for - or operation == "get_principal" - or (prior_task_control and operation in _PRIOR_TASK_CONTROL_METHODS) - else AdcpError( - "PERMISSION_DENIED", - message="Caller is not authorized for this operation", - recovery="correctable", - ) - ) - return replace(signer, principal_id=None), error - - def _is_spend_committing(self, signer: VerifiedSigner, operation: str) -> bool: - payload = signer.parsed_body - params: Mapping[str, Any] = {} - if isinstance(payload, Mapping): - if self._transport == "mcp": - envelope = payload.get("params") - arguments = envelope.get("arguments") if isinstance(envelope, Mapping) else None - if isinstance(arguments, Mapping): - params = arguments - else: - from adcp.server.a2a_server import parse_a2a_jsonrpc_skill - - _skill, arguments = parse_a2a_jsonrpc_skill(dict(payload), self._message_parser) - if isinstance(arguments, Mapping): - params = arguments - return self._config.spend_classifier(operation, params) - - async def _reject_wba( - self, scope: Any, receive: Any, send: Any, exc: WbaVerificationError - ) -> None: - payload = ( - { - "errors": [ - { - "code": "SERVICE_UNAVAILABLE", - "message": "Signer identity is temporarily unavailable", - "recovery": "transient", - } - ] - } - if isinstance(exc, WbaUnavailableError) - else ( - {"errors": [{"code": "PERMISSION_DENIED", "message": "Authentication failed"}]} - if isinstance(exc, WbaAuthorizationError) - else {"error": "Request signature verification failed"} - ) - ) - response = JSONResponse(payload, status_code=exc.status, headers=exc.headers) - await response(scope, receive, send) - - async def _project_wba_authentication_error( - self, scope: Any, receive: Any, send: Any, body: bytes, failure: WbaVerificationError - ) -> None: - """Render a key rejection or outage without authenticating its origin.""" - from starlette.authentication import UnauthenticatedUser - - from adcp.decisioning.types import AdcpError - from adcp.server.mcp_tools import ADCP_TOOL_DEFINITIONS - - if not self._defer_authorization_errors: - if isinstance(failure, WbaUnavailableError): - await self._reject_wba(scope, receive, send, failure) - return - await JSONResponse({"error": "unauthenticated"}, status_code=401)(scope, receive, send) - return - target = _jsonrpc_target(body, self._transport, self._message_parser, parse_skill=True) - if not any(tool["name"] == target.operation for tool in ADCP_TOOL_DEFINITIONS): - if isinstance(failure, WbaUnavailableError): - await self._reject_wba(scope, receive, send, failure) - return - await JSONResponse({"error": "unauthenticated"}, status_code=401)(scope, receive, send) - return - if target.a2a_parsed is not None: - from adcp.server.a2a_server import _A2A_PARSED_REQUEST_SCOPE_KEY - - scope[_A2A_PARSED_REQUEST_SCOPE_KEY] = target.a2a_parsed - error = ( - AdcpError( - "SERVICE_UNAVAILABLE", - message="Signer identity is temporarily unavailable", - recovery="transient", - ) - if isinstance(failure, WbaUnavailableError) - else AdcpError( - "PERMISSION_DENIED", message="Authentication failed", recovery="correctable" - ) - ) - scope.pop(VERIFIED_SIGNER_SCOPE_KEY, None) - scope.pop(_FRAMEWORK_VERIFIED_SCOPE_KEY, None) - state = scope.setdefault("state", {}) - state.pop(VERIFIED_SIGNER_SCOPE_KEY, None) - state[_WBA_AUTHORIZATION_ERROR_KEY] = error - state[_WBA_SPEND_COMMITTING_KEY] = None - scope[_WBA_ERROR_PROJECTION_ONLY_KEY] = True - scope["user"] = UnauthenticatedUser() - scope.pop("auth", None) - signer_token = current_verified_signer.set(None) - error_token = current_wba_authorization_error.set(error) - spend_token = current_wba_spend_committing.set(None) - projection_token = current_wba_error_projection_only.set(True) - try: - await self._app(scope, receive, send) - finally: - current_verified_signer.reset(signer_token) - current_wba_authorization_error.reset(error_token) - current_wba_spend_committing.reset(spend_token) - current_wba_error_projection_only.reset(projection_token) + return replace(signer, agent_url=key.agent_url if key is not None else None) async def _resolve(self, keyid: str) -> ResolvedSignerKey | None: try: @@ -928,86 +467,15 @@ async def _resolve(self, keyid: str) -> ResolvedSignerKey | None: ) from exc async def _call_with_signer( - self, - scope: Any, - receive: Any, - send: Any, - signer: VerifiedSigner, - *, - authorization_error: AdcpError | None = None, - prior_task_control: bool = False, + self, scope: Any, receive: Any, send: Any, signer: VerifiedSigner ) -> None: - if authorization_error is not None and not self._defer_authorization_errors: - if authorization_error.code == "SERVICE_UNAVAILABLE": - await JSONResponse({"errors": [authorization_error.to_wire()]}, status_code=503)( - scope, receive, send - ) - return - await JSONResponse({"error": "unauthenticated"}, status_code=401)(scope, receive, send) - return - target = _jsonrpc_target( - json.dumps(signer.parsed_body).encode(), - self._transport, - self._message_parser, - parse_skill=True, - ) - raw_method = ( - signer.parsed_body.get("method") if isinstance(signer.parsed_body, Mapping) else None - ) - skill_dispatch = target.operation is not None or ( - raw_method == "tools/call" - if self._transport == "mcp" - else isinstance(raw_method, str) and raw_method in _A2A_MESSAGE_METHODS - ) - if authorization_error is not None and not skill_dispatch: - # Protocol methods do not enter the AdCP skill error binding. - if ( - signer.profile == "web-bot-auth" - and self._bearer_configured - and _header(scope, b"authorization") is not None - ): - await self._app(scope, receive, send) - elif authorization_error.code == "SERVICE_UNAVAILABLE": - await JSONResponse({"errors": [authorization_error.to_wire()]}, status_code=503)( - scope, receive, send - ) - else: - await JSONResponse({"error": "unauthenticated"}, status_code=401)( - scope, receive, send - ) - return scope[VERIFIED_SIGNER_SCOPE_KEY] = signer scope[_FRAMEWORK_VERIFIED_SCOPE_KEY] = True scope.setdefault("state", {})[VERIFIED_SIGNER_SCOPE_KEY] = signer - scope["state"][_WBA_AUTHORIZATION_ERROR_KEY] = authorization_error - spend_committing = None - if signer.profile == "web-bot-auth": - spend_committing = self._is_spend_committing( - signer, target.operation or target.method or "" - ) - scope["state"][_WBA_SPEND_COMMITTING_KEY] = spend_committing - prior_task_control = ( - prior_task_control - and signer.profile == "web-bot-auth" - and signer.principal_id is None - and target.method in _PRIOR_TASK_CONTROL_METHODS - and target.operation is None - and authorization_error is None - ) - if prior_task_control: - # This restores only the old task-store owner. The signer remains - # identity-established, and no skill dispatch can carry this marker. - scope[_WBA_PRIOR_TASK_CONTROL_KEY] = signer.agent_url - scope.pop("auth", None) if ( self._transport == "a2a" and signer.agent_url is not None - and ( - signer.profile != "web-bot-auth" - or signer.principal_id is not None - or prior_task_control - ) - and (prior_task_control or not getattr(scope.get("user"), "is_authenticated", False)) + and not getattr(scope.get("user"), "is_authenticated", False) ): # a2a-sdk scopes task ownership by the authenticated user. Without # this every signed buyer shares one owner and can read or cancel @@ -1017,14 +485,10 @@ async def _call_with_signer( scope["user"] = _A2AAuthenticatedUser(display_name=signer.agent_url) token = current_verified_signer.set(signer) - error_token = current_wba_authorization_error.set(authorization_error) - spend_token = current_wba_spend_committing.set(spend_committing) try: await self._app(scope, receive, send) finally: current_verified_signer.reset(token) - current_wba_authorization_error.reset(error_token) - current_wba_spend_committing.reset(spend_token) @staticmethod async def _reject(send: Any, exc: SignatureVerificationError) -> None: @@ -1090,31 +554,6 @@ def scope_has_verified_signer(scope: Mapping[str, Any]) -> bool: return scope.get(_FRAMEWORK_VERIFIED_SCOPE_KEY) is True -def scope_has_wba_error_projection(scope: Mapping[str, Any]) -> bool: - """A known AdCP skill admitted only to render a framework-owned denial.""" - from adcp.decisioning.types import AdcpError - - state = scope.get("state") - return ( - scope.get(_WBA_ERROR_PROJECTION_ONLY_KEY) is True - and isinstance(state, Mapping) - and isinstance(state.get(_WBA_AUTHORIZATION_ERROR_KEY), AdcpError) - ) - - -def scope_has_wba_prior_task_control(scope: Mapping[str, Any]) -> bool: - """A delisted verified origin admitted solely to its pre-existing task owner.""" - signer = scope.get(VERIFIED_SIGNER_SCOPE_KEY) - return ( - scope.get(_FRAMEWORK_VERIFIED_SCOPE_KEY) is True - and isinstance(signer, VerifiedSigner) - and signer.profile == "web-bot-auth" - and signer.principal_id is None - and signer.agent_url is not None - and scope.get(_WBA_PRIOR_TASK_CONTROL_KEY) == signer.agent_url - ) - - def apply_verified_signer( context: ToolContext | None, request_context: Any, @@ -1129,71 +568,24 @@ def apply_verified_signer( owns identity there. Also unchanged when the request carried no framework-verified signature. """ - from adcp.decisioning.context import AuthInfo - from adcp.server.base import ToolContext - - request_scope = getattr(request_context, "scope", None) - if isinstance(request_scope, Mapping): - state = request_scope.get("state") - authorization_error = ( - state.get(_WBA_AUTHORIZATION_ERROR_KEY) if isinstance(state, Mapping) else None - ) - spend_committing = ( - state.get(_WBA_SPEND_COMMITTING_KEY) if isinstance(state, Mapping) else None - ) - else: - authorization_error = current_wba_authorization_error.get() - spend_committing = current_wba_spend_committing.get() signer = verified_signer_for_request(request_context) if signer is None or signer.agent_url is None: - if authorization_error is None: - return context - context = copy.copy(context) if context is not None else ToolContext() - context.metadata = {**context.metadata, _WBA_AUTHORIZATION_ERROR_KEY: authorization_error} - projection_only = ( - scope_has_wba_error_projection(request_scope) - if isinstance(request_scope, Mapping) - else current_wba_error_projection_only.get() - ) - if projection_only: - context.caller_identity = None - context.metadata.pop("adcp.auth_info", None) - context.metadata.pop("auth_info", None) return context + + from adcp.decisioning.context import AuthInfo + from adcp.server.base import ToolContext + if context is None: context = ToolContext() else: existing = context.metadata.get("adcp.auth_info") if existing is not None and getattr(existing, "credential", None) is not None: - if signer.profile != "web-bot-auth" and authorization_error is not None: - # Independent bearer credentials cannot revive the deprecated - # signed profile after an origin's approved migration. - context = copy.copy(context) - context.metadata = { - **context.metadata, - _WBA_AUTHORIZATION_ERROR_KEY: authorization_error, - } return context # Copy so a factory that returns a shared instance never carries one # request's signer into another. context = copy.copy(context) context.metadata = dict(context.metadata) - if authorization_error is not None: - context.metadata[_WBA_AUTHORIZATION_ERROR_KEY] = authorization_error - if type(spend_committing) is bool: - context.metadata[_WBA_SPEND_COMMITTING_KEY] = spend_committing - context.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer( - signer, - extra=( - { - "signing_profile": signer.profile, - "principal_id": signer.principal_id, - "wba_spend_committing": spend_committing, - } - if signer.profile == "web-bot-auth" - else None - ), - ) + context.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer(signer) context.caller_identity = signer.agent_url return context @@ -1250,7 +642,6 @@ def wrap_with_signature_verification( transport: Literal["mcp", "a2a"], message_parser: Any = None, bearer_configured: bool = False, - defer_authorization_errors: bool = True, ) -> Any: """Wrap ``app`` with verification when ``config`` is set; else return it.""" if config is None: @@ -1261,7 +652,6 @@ def wrap_with_signature_verification( transport=transport, message_parser=message_parser, bearer_configured=bearer_configured, - defer_authorization_errors=defer_authorization_errors, ) diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index 554df0f1d..9b3c5bc8b 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -132,7 +132,6 @@ operation_needs_signing, signing_profile_for_adcp_version, ) -from adcp.signing.bindings import OriginBindingResolver, OriginBindings from adcp.signing.brand_authz import ( BrandAuthorizationReason, BrandAuthorizationResolver, @@ -149,7 +148,6 @@ from adcp.signing.canonical import ( SignatureInputLabel, build_signature_base, - canonical_origin, canonicalize_authority, canonicalize_target_uri, malformed_authority_reason, @@ -296,7 +294,7 @@ ReplayStore, supports_atomic_claim, ) -from adcp.signing.revocation import RevocationChecker, RevocationDetails, RevocationList +from adcp.signing.revocation import RevocationChecker, RevocationList from adcp.signing.revocation_fetcher import ( DEFAULT_GRACE_MULTIPLIER, REVOCATION_LIST_TYP, @@ -321,7 +319,6 @@ ResolvedSignerKey, SignerKeyResolver, StaticSignerKeys, - WbaSignerKeyResolver, ) from adcp.signing.standard_webhooks import ( StandardWebhookError, @@ -331,21 +328,6 @@ from adcp.signing.standard_webhooks import ( decode_secret as decode_standard_webhook_secret, ) -from adcp.signing.trust import ( - EvaluatedTrustRecord, - GrantAuthorization, - GrantRequest, - TrustGrantEvaluator, - TrustRecordAbsentError, - TrustRecordError, - TrustRecordEvaluator, - TrustRecordMatchError, - TrustRecordNotFoundError, - TrustRecordUnavailableError, - TrustResponse, - trust_record_url, - validate_trust_record, -) from adcp.signing.verifier import ( RequestBodyMalformedError, SigningProfileVersion, @@ -355,20 +337,6 @@ strict_header_precheck, verify_request_signature, ) -from adcp.signing.wba import ( - DirectoryResponse, - DiscoveredSignerKeys, - OriginRevocationChecker, - WbaAuthorizationError, - WbaUnavailableError, - WbaVerificationError, - public_directory_key, - sign_key_directory, - sign_wba_request, - thumbprint_key_id, - verify_directory_response, - verify_wba_request, -) # Conditional import: PgReplayStore needs the [pg] extra. Always expose # the name — if psycopg isn't installed we fall through to a stub class @@ -396,39 +364,9 @@ def __init__(self, *args: object, **kwargs: object) -> None: __all__ = [ - "GrantRequest", - "WbaAuthorizationError", - "WbaUnavailableError", - "RevocationDetails", - "OriginRevocationChecker", - "EvaluatedTrustRecord", - "GrantAuthorization", - "TrustGrantEvaluator", - "TrustRecordAbsentError", - "TrustRecordError", - "TrustRecordEvaluator", - "TrustRecordMatchError", - "TrustRecordNotFoundError", - "TrustRecordUnavailableError", - "TrustResponse", - "trust_record_url", - "validate_trust_record", - "canonical_origin", - "OriginBindingResolver", - "OriginBindings", - "DirectoryResponse", - "DiscoveredSignerKeys", - "WbaVerificationError", - "public_directory_key", - "sign_key_directory", - "sign_wba_request", - "thumbprint_key_id", - "verify_directory_response", - "verify_wba_request", "JwksUriSignerKeys", "ResolvedSignerKey", "SignerKeyResolver", - "WbaSignerKeyResolver", "StaticSignerKeys", "ALG_ED25519", "ALG_ES256", diff --git a/src/adcp/signing/_header_precheck.py b/src/adcp/signing/_header_precheck.py index 819b4d677..e59bba8a1 100644 --- a/src/adcp/signing/_header_precheck.py +++ b/src/adcp/signing/_header_precheck.py @@ -34,7 +34,6 @@ from collections.abc import Mapping, Sequence -from adcp.signing._structured_fields import reject_dictionary_duplicates from adcp.signing.canonical import ( TargetUriMalformedError, _split_or_reject, @@ -70,7 +69,6 @@ _DICTIONARY_HEADERS = { "signature": "Signature", "signature-input": "Signature-Input", - "signature-agent": "Signature-Agent", "content-digest": "Content-Digest", } @@ -110,10 +108,6 @@ def strict_header_precheck( reason = _duplicate_dictionary_key_reason(value, header_name) if reason is not None: return reason - try: - reject_dictionary_duplicates(value) - except ValueError as exc: - return f"{header_name} is malformed: {exc}" return _authority_reason(pairs=pairs, url=url) diff --git a/src/adcp/signing/_identity_schemas/UPSTREAM.json b/src/adcp/signing/_identity_schemas/UPSTREAM.json deleted file mode 100644 index d781f6554..000000000 --- a/src/adcp/signing/_identity_schemas/UPSTREAM.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "repository": "adcontextprotocol/adcp", - "sources": { - "core/agent-onboarding.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "core/agent-onboarding-tier.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "core/agent-onboarding-requirement.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "core/buyer-onboarding-posture.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "enums/agent-onboarding-mode.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "enums/agent-onboarding-requirement-kind.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "enums/buyer-onboarding-mode.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "core/principal-relationship.json": { - "pr": 8168, - "commit": "45540941dfd61e80ab8a463f780cb147c82a32f0" - }, - "trust/v1/trust.json": { - "pr": 8169, - "commit": "3ce6a14d7afaa71fa78ddf2223ed0421e01205ae" - }, - "core/identity-relying-party.json": { - "pr": 8169, - "commit": "3ce6a14d7afaa71fa78ddf2223ed0421e01205ae" - } - }, - "baseline_dependencies": { - "directory": "schemas/cache/3.2", - "note": "Offline references not present in the preview sources use the SDK pinned AdCP 3.2 cache." - }, - "composition": { - "trust/v1/trust.json": [ - "agent.profiles.adcp.onboarding from PR #8170", - "record_profiles.adcp.onboarding from PR #8170" - ] - } -} diff --git a/src/adcp/signing/_identity_schemas/bundle.json b/src/adcp/signing/_identity_schemas/bundle.json deleted file mode 100644 index bbf2961d0..000000000 --- a/src/adcp/signing/_identity_schemas/bundle.json +++ /dev/null @@ -1,1577 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "IdentityPreview", - "definitions": { - "enums_agent_onboarding_mode": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Agent Onboarding Mode", - "x-status": "experimental", - "description": "How a seller onboards buyer agents for one billing party or for sandbox access. Absent means undeclared, never invite_only.", - "type": "string", - "enum": [ - "open", - "application", - "invite_only" - ], - "enumDescriptions": { - "open": "A buyer agent that meets every listed requirement and whose identity the seller can establish from the agent's published keys (the Web Bot Auth profile) may proceed without prior contact. open binds identity, not spend: spend still needs an existing billing relationship with the billed party, and a brand outside the buyer's operator domain needs a grant. A seller that only maps opaque, seller-issued credentials MUST NOT declare open.", - "application": "The buyer completes an out-of-band step at url before the seller recognizes it for this mode.", - "invite_only": "The seller does not accept unsolicited buyer agents for this mode. url MAY be absent." - } - }, - "enums_agent_onboarding_requirement_kind": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Agent Onboarding Requirement Kind", - "x-status": "experimental", - "description": "What a counterparty must satisfy before an onboarding mode applies. Each kind states the direction it applies to; a kind used in the other direction is ignored. Readers MUST treat an unknown kind as unsatisfiable by machine and fall back to the posture's message and url.", - "type": "string", - "x-extensible": true, - "enum": [ - "agent_identity", - "grant", - "billing_entity", - "contract", - "credit_application", - "aao_verified", - "storyboard", - "sandbox", - "experimental_feature" - ], - "enumDescriptions": { - "agent_identity": "Seller gating a buyer. The buyer agent's origin is listed in its operator's trust.json with the role the seller needs (adcp:buying).", - "grant": "Seller gating a buyer. A demand-side grant authorizes the buyer's operator for the account's brand (DR-0025); implicit when the operator is the brand.", - "billing_entity": "Seller gating a buyer. The buyer supplies a billing entity (core/business-entity.json) on sync_accounts.", - "contract": "Seller gating a buyer. A signed agreement exists; details at url.", - "credit_application": "Seller gating a buyer. A credit application is completed at url, or later through sync_accounts setup.url.", - "aao_verified": "Buyer gating a seller. The seller holds an AgenticAdvertising.org Verified badge; role is required, and verification_modes, specialisms, and adcp_version narrow it.", - "storyboard": "Buyer gating a seller. The seller passes the named storyboard or specialism (id).", - "sandbox": "Buyer gating a seller. The seller declares account.sandbox: true.", - "experimental_feature": "Buyer gating a seller. The seller lists the named feature id (id) in experimental_features." - } - }, - "core_ext": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Extension Object", - "description": "Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.", - "type": "object", - "additionalProperties": true - }, - "core_agent_onboarding_requirement": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Agent Onboarding Requirement", - "x-status": "experimental", - "description": "One requirement an onboarding mode imposes, discriminated by kind. Only aao_verified, storyboard, and experimental_feature carry fields; other kinds are the kind alone. The requirement describes the gate the publishing party applies on its own side; it creates no conformance obligation on the counterparty.", - "type": "object", - "properties": { - "kind": { - "$ref": "#/definitions/enums_agent_onboarding_requirement_kind" - }, - "role": { - "type": "string", - "description": "aao_verified: the badge role required, such as media-buy." - }, - "verification_modes": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "aao_verified: accepted verification modes, as the badge token names them." - }, - "specialisms": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "aao_verified: specialisms the badge must include." - }, - "adcp_version": { - "type": "string", - "description": "aao_verified: the AdCP release the badge must cover." - }, - "id": { - "type": "string", - "description": "storyboard: the storyboard or specialism id. experimental_feature: the feature id." - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - }, - "required": [ - "kind" - ], - "additionalProperties": true - }, - "core_agent_onboarding_tier": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Agent Onboarding Tier", - "x-status": "experimental", - "description": "The onboarding mode for one billing party, or for sandbox access, with its requirements and an optional page of its own.", - "type": "object", - "properties": { - "mode": { - "$ref": "#/definitions/enums_agent_onboarding_mode" - }, - "url": { - "type": "string", - "format": "uri", - "description": "Where a human starts for this mode. Defaults to the posture's top-level url. Untrusted: surface it, never fetch or execute it." - }, - "requirements": { - "type": "array", - "items": { - "$ref": "#/definitions/core_agent_onboarding_requirement" - }, - "description": "What the counterparty must satisfy for this mode." - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - }, - "required": [ - "mode" - ], - "additionalProperties": true - }, - "enums_billing_party": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Billing Party", - "description": "Which party the seller invoices for a buyer\u2013storefront account relationship. Determines the billing entity and invoicing flow, not a payment rail, clearing intermediary, or per-media-buy settlement route. AdCP does not perform in-protocol payment or settlement.", - "type": "string", - "enum": [ - "operator", - "agent", - "advertiser" - ], - "enumDescriptions": { - "operator": "Seller invoices the operator \u2014 agency or brand buying direct", - "agent": "Agent consolidates billing across brands and is invoiced by the seller", - "advertiser": "Seller invoices the advertiser directly, even when a different operator places orders on their behalf" - } - }, - "core_agent_onboarding": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Agent Onboarding Posture", - "x-status": "experimental", - "description": "How a seller onboards buyer agents: a mode per billing party and for sandbox, with requirements, and where a human starts. Seller-wide and identical for every caller, served on the unauthenticated get_adcp_capabilities fetch, and absent from error payloads, so it cannot act as a per-agent onboarding oracle. Its content is advisory; it changes no error code and no oracle clamp. An absent key means undeclared, never invite_only. See specs/agent-identity-3-3.md R7 (#8113).", - "type": "object", - "properties": { - "sandbox": { - "$ref": "#/definitions/core_agent_onboarding_tier", - "description": "Onboarding for sandbox accounts. Valid only when account.sandbox is true. Sandbox carries no spend, so open here grants identity and sandbox access only." - }, - "billing": { - "type": "object", - "description": "Onboarding per billing party, keyed by billing-party values. Keys MUST be a subset of account.supported_billing. Under open, spend needs an existing seller-side billing relationship with the billed party: the operator domain under operator, the brand's domain under advertiser.", - "propertyNames": { - "$ref": "#/definitions/enums_billing_party" - }, - "additionalProperties": { - "$ref": "#/definitions/core_agent_onboarding_tier" - } - }, - "url": { - "type": "string", - "format": "uri", - "description": "Where a human at the buyer starts when a mode has no url of its own. Untrusted: surface it, never fetch or execute it." - }, - "documentation_url": { - "type": "string", - "format": "uri", - "description": "The seller's onboarding and integration documentation. Untrusted, like url." - }, - "message": { - "type": "string", - "description": "Human-readable summary of the process and what to have ready. The fallback whenever a reader does not understand a requirement." - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - }, - "additionalProperties": true - }, - "enums_buyer_onboarding_mode": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Buyer Onboarding Mode", - "x-status": "experimental", - "description": "How a buyer considers sellers for its plans. Distinct from the seller-side agent-onboarding-mode, whose open means something else. Absent means undeclared.", - "type": "string", - "enum": [ - "considers_unsolicited", - "application", - "invite_only" - ], - "enumDescriptions": { - "considers_unsolicited": "A seller that meets every listed requirement is in the buyer's consideration set without prior contact; the buyer discovers and calls it. Nothing is sent to the buyer.", - "application": "The seller completes an out-of-band step at url before the buyer considers it.", - "invite_only": "The buyer does not consider unsolicited sellers. url MAY be absent." - } - }, - "core_buyer_onboarding_posture": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Buyer Onboarding Posture", - "x-status": "experimental", - "description": "How a buying agent considers sellers: a mode, the requirements a seller must meet, and where a seller's human starts. Published in the buyer's trust.json agent entry (profiles.adcp.onboarding), or as a record-level default (record_profiles.adcp.onboarding). Public and identical for every reader. A buyer that serves get_adcp_capabilities is authoritative there; trust.json may mirror it. See specs/agent-identity-3-3.md R7 (#8114).", - "type": "object", - "properties": { - "mode": { - "$ref": "#/definitions/enums_buyer_onboarding_mode" - }, - "requirements": { - "type": "array", - "items": { - "$ref": "#/definitions/core_agent_onboarding_requirement" - }, - "description": "What a seller must satisfy. Uses the kinds that apply when a buyer gates a seller (aao_verified, storyboard, sandbox, experimental_feature); others are ignored." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Where a seller's human starts. Untrusted: surface it, never fetch or execute it." - }, - "documentation_url": { - "type": "string", - "format": "uri", - "description": "The buyer's documentation for sellers. Untrusted, like url." - }, - "message": { - "type": "string", - "description": "Human-readable summary; the fallback whenever a reader does not understand a requirement." - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - }, - "required": [ - "mode" - ], - "additionalProperties": true - }, - "core_principal_relationship": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Principal Relationship", - "x-status": "experimental", - "description": "The seller's commercial relationship with the authenticated caller, returned by get_principal only to that caller. Present when a relationship or an application exists; omitted when the seller has no relationship with the caller and no application in progress, which is the identity-established state of a verified signer the seller has not bound. Because it is returned only to the authenticated caller, it never acts as an onboarding oracle. See specs/agent-identity-3-3.md R8.", - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "pending", - "active", - "suspended", - "blocked" - ], - "enumDescriptions": { - "pending": "An application or approval is in progress. setup, when present, carries the caller's next step.", - "active": "The relationship is in good standing.", - "suspended": "The relationship is temporarily paused; calls that need it fail with AGENT_SUSPENDED.", - "blocked": "The relationship is permanently denied; calls that need it fail with AGENT_BLOCKED." - }, - "description": "Current state of the relationship." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "When the relationship lapses unless renewed. Absent means no scheduled expiry. principal.changed fires with reason relationship_expiring at a seller-declared lead time before this instant." - }, - "setup": { - "type": "object", - "description": "The caller's next step while status is pending, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide.", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "URL where a human at the buyer completes the step. Untrusted: surface it, never fetch or execute it." - }, - "message": { - "type": "string", - "description": "Human-readable description of what is needed." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "When this step expires." - } - }, - "required": [ - "message" - ], - "additionalProperties": true - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - }, - "required": [ - "status" - ], - "additionalProperties": true - }, - "enums_right_use": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Right Use", - "description": "Types of rights usage that can be licensed through the brand protocol. Aligned with DDEX UseType direction for interoperability with music and media rights systems.", - "x-status": "experimental", - "type": "string", - "enum": [ - "likeness", - "voice", - "name", - "endorsement", - "motion_capture", - "signature", - "catchphrase", - "sync", - "background_music", - "editorial", - "commercial", - "ai_generated_image" - ], - "enumDescriptions": { - "likeness": "Visual representation of a person's appearance, including AI-generated imagery", - "voice": "Audio reproduction of a person's voice, including AI-generated speech", - "name": "Use of a person's name in advertising or promotional materials", - "endorsement": "Implied or explicit endorsement of a product or service by a person", - "motion_capture": "Use of a person's movement patterns for animation or video", - "signature": "Use of a person's signature or autograph", - "catchphrase": "Use of a person's well-known phrases or sayings", - "sync": "Synchronization of music with visual media (ads, video)", - "background_music": "Use of music as background audio in advertising", - "editorial": "Use in editorial or informational contexts", - "commercial": "Use in commercial advertising and promotional contexts", - "ai_generated_image": "License to produce AI-generated still imagery derived from the rights holder's IP (brand assets, character designs, talent likeness). Distinct from likeness/voice by output modality \u2014 a get_rights query filtered on ai_generated_image returns rights whose pricing options cover image generation, regardless of the underlying IP category." - } - }, - "enums_right_type": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Right Type", - "description": "Categories of intellectual property rights that can be licensed through the brand protocol.", - "x-status": "experimental", - "type": "string", - "enum": [ - "talent", - "character", - "brand_ip", - "music", - "stock_media" - ], - "enumDescriptions": { - "talent": "Rights to a real person's likeness, voice, name, or endorsement (athletes, celebrities, influencers)", - "character": "Rights to fictional characters for advertising or co-branding", - "brand_ip": "Rights to brand intellectual property (logos, trademarks, trade dress)", - "music": "Rights to musical compositions or recordings (sync, background music)", - "stock_media": "Rights to stock photography, video, or audio collections" - } - }, - "core_brand_id": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Brand ID", - "description": "Identifier for a brand within a house portfolio. Must be lowercase alphanumeric with underscores only. The house chooses this identifier.", - "type": "string", - "pattern": "^[a-z0-9_]+$", - "x-entity": "advertiser_brand", - "examples": [ - "tide", - "cheerios", - "air_jordan", - "nike", - "pampers" - ] - }, - "enums_digital_source_type": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Digital Source Type", - "description": "Classification of AI involvement in content creation, aligned with IPTC digitalsourcetype vocabulary. Used in provenance metadata to declare how a creative asset, artifact, or individual asset was produced.", - "type": "string", - "enum": [ - "digital_capture", - "digital_creation", - "trained_algorithmic_media", - "composite_with_trained_algorithmic_media", - "algorithmic_media", - "composite_capture", - "composite_synthetic", - "human_edits", - "data_driven_media" - ], - "enumDescriptions": { - "digital_capture": "Captured by a digital device (camera, scanner, screen recording) with no AI involvement", - "digital_creation": "Created by a human using digital tools (Photoshop, Illustrator, After Effects) without AI generation", - "trained_algorithmic_media": "Generated entirely by a trained AI model (DALL-E, Midjourney, Stable Diffusion, Sora)", - "composite_with_trained_algorithmic_media": "Human-created content combined with AI-generated elements (e.g., photo with AI background)", - "algorithmic_media": "Produced by deterministic algorithms without machine learning (procedural generation, rule-based systems)", - "composite_capture": "Multiple digital captures composited together without AI", - "composite_synthetic": "Composite of multiple elements where at least one is AI-generated (e.g., stock photo composited with AI-generated background)", - "human_edits": "Content augmented, corrected, or enhanced by humans using non-generative tools", - "data_driven_media": "Assembled from structured data feeds (DCO templates, product catalogs, weather-triggered variants)" - } - }, - "enums_embedded_provenance_method": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Embedded Provenance Method", - "description": "How provenance data is carried within the content stream. Distinguishes between format-level manifest embedding (where the manifest travels inside the file container) and provenance markers (where invisible markers in the content itself encode or reference a provenance record). Both survive operations that strip sidecar metadata, but provenance markers additionally survive content-level transformations such as copy-paste, reformatting, and CMS re-encoding.", - "type": "string", - "enum": [ - "manifest_wrapper", - "provenance_markers" - ], - "enumDescriptions": { - "manifest_wrapper": "A provenance manifest embedded in the file container per format-specific rules (e.g., JUMBF box in JPEG, C2PATextManifestWrapper in plaintext per C2PA Section A.7). The manifest travels with the file but is tied to the file's byte structure.", - "provenance_markers": "Invisible markers embedded within the content stream that encode or reference a provenance record. Designed to survive reformatting, copy-paste, CMS ingestion, and ad-server transcoding that breaks file-level bindings." - } - }, - "enums_watermark_media_type": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Watermark Media Type", - "description": "Media category of a content watermark. Identifies what kind of content the watermark is applied to, which determines the detection and verification methods required.", - "type": "string", - "enum": [ - "audio", - "image", - "video", - "text" - ], - "enumDescriptions": { - "audio": "Watermark applied to audio content (e.g., spread-spectrum, echo hiding)", - "image": "Watermark applied to image content (e.g., spatial domain, frequency domain)", - "video": "Watermark applied to video content (e.g., per-frame image watermarking, temporal watermarking)", - "text": "Watermark applied to text content (e.g., synonym substitution, structural modification)" - } - }, - "enums_c2pa_watermark_action": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "C2PA Watermark Action", - "description": "C2PA action classification for a content watermark. Distinguishes watermarks that are cryptographically linked to a C2PA manifest (bound) from watermarks that exist independently (unbound). Aligns with the c2pa.watermarked.* action family in the C2PA action taxonomy.", - "type": "string", - "enum": [ - "c2pa.watermarked.bound", - "c2pa.watermarked.unbound" - ], - "enumDescriptions": { - "c2pa.watermarked.bound": "Watermark linked to a C2PA manifest for this asset. The watermark and manifest are mutually reinforcing: the manifest references the watermark, and the watermark can be used to locate the manifest.", - "c2pa.watermarked.unbound": "Watermark independent of any C2PA manifest. Applied before any provenance signing event (e.g., by the AI generator at creation time) or in pipelines where no manifest is present." - } - }, - "enums_disclosure_persistence": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Disclosure Persistence", - "description": "How long a disclosure must persist during content playback or display. Different jurisdictions and regulations require different persistence behaviors for AI-generated content labels. When multiple sources specify persistence for the same jurisdiction (e.g., brief and provenance), the most restrictive mode applies: continuous > initial > flexible.", - "type": "string", - "enum": [ - "continuous", - "initial", - "flexible" - ], - "enumDescriptions": { - "continuous": "Disclosure must remain visible or audible throughout the entire content display duration. For video and audio, this means the full playback duration. For static formats (display, DOOH), this means the full display slot. For DOOH specifically, 'content duration' means the ad's display slot within the rotation, not the screen's full rotation cycle.", - "initial": "Disclosure must appear at the start of content for a minimum duration before it may be removed. Pair with min_duration_ms in render_guidance or creative brief to specify the required duration.", - "flexible": "Disclosure presence is sufficient; placement timing and duration are at the publisher's discretion" - } - }, - "enums_disclosure_position": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Disclosure Position", - "description": "Where a required disclosure should appear within a creative. Used by creative briefs to specify disclosure placement and by formats to declare which positions they can render.", - "type": "string", - "enum": [ - "prominent", - "footer", - "audio", - "subtitle", - "overlay", - "end_card", - "pre_roll", - "companion" - ] - }, - "core_provenance": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Provenance", - "description": "Declares how content was produced, whether AI was involved, and what disclosure obligations apply. Attaches to creative manifests, individual assets, or content-standards artifacts. When present at multiple levels, the most specific provenance object replaces the inherited one entirely (no field-level merging). Provenance is a claim by the declaring party \u2014 receiving parties should verify claims independently via their own detection tools.", - "type": "object", - "properties": { - "digital_source_type": { - "$ref": "#/definitions/enums_digital_source_type", - "description": "IPTC-aligned classification of AI involvement in producing this content" - }, - "synthetic_depiction": { - "type": "boolean", - "description": "Assessed declaration of whether the content synthetically depicts a real or fictional person performing or appearing in a way that was generated or materially manipulated rather than captured as depicted. `true` covers both a fully synthetic performer and material manipulation of a real performer; `false` is an assessed declaration that the content does not contain such a depiction. Absence means the content has not been assessed for synthetic depiction. This field does not claim consent, legality, or independent verification, and receivers MUST NOT derive it solely from `digital_source_type`." - }, - "ai_tool": { - "type": "object", - "description": "AI system used to generate or modify this content. Aligns with IPTC 2025.1 AI metadata fields and C2PA claim_generator.", - "properties": { - "name": { - "type": "string", - "description": "Name of the AI tool or model (e.g., 'DALL-E 3', 'Stable Diffusion XL', 'Gemini')" - }, - "version": { - "type": "string", - "description": "Version identifier for the AI tool or model (e.g., '25.1', '0125', '2.1'). For generative models, use the model version rather than the API version." - }, - "provider": { - "type": "string", - "description": "Organization that provides the AI tool (e.g., 'OpenAI', 'Stability AI', 'Google')" - } - }, - "required": [ - "name" - ] - }, - "human_oversight": { - "type": "string", - "description": "Level of human involvement in the AI-assisted creation process. Independent of `disclosure.required` \u2014 the protocol does not derive disclosure obligations from oversight level. Some regulations include carve-outs for human-edited or human-directed AI output, but those carve-outs have factual prerequisites the schema cannot evaluate. Asserting `edited` or `directed` does not by itself justify `disclosure.required: false`.", - "enum": [ - "none", - "prompt_only", - "selected", - "edited", - "directed" - ], - "enumDescriptions": { - "none": "Fully automated with no human involvement in generation", - "prompt_only": "Human provided the prompt or instructions but did not review outputs", - "selected": "Human selected from multiple AI-generated candidates", - "edited": "Human edited or refined AI-generated output", - "directed": "Human directed the creative process with AI as an assistive tool" - } - }, - "declared_by": { - "type": "object", - "description": "Party declaring this provenance. Identifies who attached the provenance claim, enabling receiving parties to assess trust.", - "properties": { - "agent_url": { - "type": "string", - "format": "uri", - "description": "URL of the agent or service that declared this provenance" - }, - "role": { - "type": "string", - "enum": [ - "creator", - "advertiser", - "agency", - "platform", - "tool" - ], - "description": "Role of the declaring party in the supply chain", - "enumDescriptions": { - "creator": "The party that created or generated the content", - "advertiser": "The brand or advertiser that owns the content", - "agency": "Agency acting on behalf of the advertiser", - "platform": "Ad platform or publisher that processed the content", - "tool": "Automated tool or service that attached provenance metadata" - } - } - }, - "required": [ - "role" - ] - }, - "declared_at": { - "type": "string", - "format": "date-time", - "description": "When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance." - }, - "created_time": { - "type": "string", - "format": "date-time", - "description": "When this content was created or generated (ISO 8601)" - }, - "c2pa": { - "type": "object", - "description": "C2PA sidecar manifest reference. Links to a detached cryptographic provenance manifest for this content. Note: file-level C2PA bindings break when ad servers transcode, resize, or re-encode assets. For pipelines with intermediaries, consider embedded_provenance as the primary provenance mechanism.", - "properties": { - "manifest_url": { - "type": "string", - "format": "uri", - "description": "URL to the C2PA manifest store for this content" - } - }, - "required": [ - "manifest_url" - ] - }, - "embedded_provenance": { - "type": "array", - "description": "Provenance metadata embedded within the content stream. Each entry declares one embedding layer: structured provenance data carried inside the content itself, as distinct from sidecar references (c2pa.manifest_url). Embedded provenance survives operations that break sidecar and file-level bindings: ad-server transcoding, CMS ingestion, copy-paste, reformatting, and CDN re-encoding. For ad-tech pipelines where content passes through multiple intermediaries, embedded provenance is the reliable path for provenance that persists from declaration through delivery. This is a declaration by the embedding party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/enums_embedded_provenance_method", - "description": "How provenance data is carried within the content" - }, - "standard": { - "type": "string", - "description": "Standard the embedding conforms to, if any (e.g., 'c2pa' for C2PA Section A.7 text manifest embedding)" - }, - "provider": { - "type": "string", - "description": "Organization that performed the embedding (e.g., 'Encypher', 'Digimarc'). Display label and audit context \u2014 not a wire identifier." - }, - "verify_agent": { - "type": "object", - "description": "Buyer's representation that this embedding can be verified by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing \u2014 the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). MAY be omitted for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts).", - "properties": { - "agent_url": { - "type": "string", - "format": "uri", - "pattern": "^https://", - "description": "URL of the governance agent the buyer represents was used to embed/verify this layer. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." - }, - "feature_id": { - "type": "string", - "description": "Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `encypher.markers_present_v2`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." - } - }, - "required": [ - "agent_url" - ], - "additionalProperties": false - }, - "embedded_at": { - "type": "string", - "format": "date-time", - "description": "When the provenance data was embedded (ISO 8601)" - } - }, - "required": [ - "method", - "provider" - ] - } - }, - "watermarks": { - "type": "array", - "description": "Content watermarks applied to this asset. Each entry declares one watermarking layer: a content modification that encodes an identifier or fingerprint within the asset. Watermarks differ from embedded provenance: a watermark encodes an identifier (who generated it, who owns it), while embedded provenance carries or references a structured provenance record (the full chain of custody). A single asset may carry both. Aligns with C2PA action taxonomy: c2pa.watermarked.bound (watermark linked to a C2PA manifest) and c2pa.watermarked.unbound (watermark independent of any manifest). This is a declaration by the watermarking party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "media_type": { - "$ref": "#/definitions/enums_watermark_media_type", - "description": "Media category of the watermarked content" - }, - "provider": { - "type": "string", - "description": "Organization that applied the watermark (e.g., 'Imatag', 'Steg.AI', 'Encypher'). Display label and audit context \u2014 not a wire identifier." - }, - "verify_agent": { - "type": "object", - "description": "Buyer's representation that this watermark can be detected by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing \u2014 the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist).", - "properties": { - "agent_url": { - "type": "string", - "format": "uri", - "pattern": "^https://", - "description": "URL of the governance agent the buyer represents was used to apply/detect this watermark. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." - }, - "feature_id": { - "type": "string", - "description": "Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `imatag.watermark_detected`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." - } - }, - "required": [ - "agent_url" - ], - "additionalProperties": false - }, - "c2pa_action": { - "$ref": "#/definitions/enums_c2pa_watermark_action", - "description": "C2PA action classification for this watermark" - }, - "embedded_at": { - "type": "string", - "format": "date-time", - "description": "When the watermark was applied (ISO 8601)" - } - }, - "required": [ - "media_type", - "provider" - ] - } - }, - "disclosure": { - "type": "object", - "description": "Regulatory disclosure requirements for this content. Indicates whether AI disclosure is required and under which jurisdictions.", - "properties": { - "required": { - "type": "boolean", - "description": "The declaring party's claim that AI disclosure is required for this content under applicable regulations. This is a declared signal carried through the supply chain \u2014 useful as a routing and audit input \u2014 not a regulatory determination made by the protocol. Receiving parties remain responsible for their own jurisdictional analysis and should not treat `required: false` as compliance cover." - }, - "jurisdictions": { - "type": "array", - "description": "Jurisdictions where disclosure obligations apply", - "items": { - "type": "object", - "properties": { - "country": { - "type": "string", - "description": "ISO 3166-1 alpha-2 country code (e.g., 'US', 'DE', 'CN')" - }, - "region": { - "type": "string", - "description": "Sub-national region code (e.g., 'CA' for California, 'BY' for Bavaria)" - }, - "regulation": { - "type": "string", - "description": "Regulation identifier (e.g., 'eu_ai_act_article_50', 'ca_sb_942', 'cn_deep_synthesis')" - }, - "label_text": { - "type": "string", - "description": "Required disclosure label text for this jurisdiction, in the local language" - }, - "render_guidance": { - "type": "object", - "description": "How the disclosure should be rendered for this jurisdiction. Expresses the declaring party's intent for persistence and position based on regulatory requirements. Publishers control actual rendering but governance agents can audit whether guidance was followed.", - "minProperties": 1, - "properties": { - "persistence": { - "$ref": "#/definitions/enums_disclosure_persistence", - "description": "How long the disclosure must persist during content playback or display" - }, - "min_duration_ms": { - "type": "integer", - "minimum": 1, - "description": "Minimum display duration in milliseconds for initial persistence. Recommended when persistence is initial \u2014 without it, the duration is at the publisher's discretion. At serve time the publisher reads this from provenance since the brief is not available." - }, - "positions": { - "type": "array", - "description": "Preferred disclosure positions in priority order. The first position a format supports should be used.", - "items": { - "$ref": "#/definitions/enums_disclosure_position" - }, - "minItems": 1, - "uniqueItems": true - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - } - } - }, - "required": [ - "country", - "regulation" - ] - }, - "minItems": 1 - } - }, - "required": [ - "required" - ] - }, - "verification": { - "type": "array", - "description": "Third-party verification or detection results for this content. Multiple services may independently evaluate the same content. Provenance is a claim \u2014 verification results attached by the declaring party are supplementary. The enforcing party (e.g., seller/publisher) should run its own verification via get_creative_features or calibrate_content.", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "verified_by": { - "type": "string", - "description": "Name of the verification service (e.g., 'DoubleVerify', 'Hive Moderation', 'Reality Defender')" - }, - "verified_time": { - "type": "string", - "format": "date-time", - "description": "When the verification was performed (ISO 8601)" - }, - "result": { - "type": "string", - "enum": [ - "authentic", - "ai_generated", - "ai_modified", - "inconclusive" - ], - "description": "Verification outcome" - }, - "confidence": { - "type": "number", - "minimum": 0, - "maximum": 1, - "description": "Confidence score of the verification result (0.0 to 1.0)" - }, - "details_url": { - "type": "string", - "format": "uri", - "description": "URL to the full verification report" - } - }, - "required": [ - "verified_by", - "result" - ] - } - }, - "ext": { - "$ref": "#/definitions/core_ext" - } - } - }, - "core_assets_image_asset": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Image Asset", - "description": "Image asset with URL and dimensions", - "type": "object", - "properties": { - "asset_type": { - "type": "string", - "const": "image", - "description": "Discriminator identifying this as an image asset. See /schemas/creative/asset-types for the registry." - }, - "url": { - "type": "string", - "format": "uri", - "description": "URL to the image asset" - }, - "width": { - "type": "integer", - "minimum": 1, - "description": "Width in pixels" - }, - "height": { - "type": "integer", - "minimum": 1, - "description": "Height in pixels" - }, - "file_size_bytes": { - "type": "integer", - "minimum": 1, - "description": "Image file size in bytes. Required by agents that advertise a max_file_size_kb constraint." - }, - "pixel_ratio": { - "type": "number", - "exclusiveMinimum": 0, - "description": "Intrinsic pixels per logical render pixel (for example `2` for a 600\u00d7500 image intended to render at 300\u00d7250). Optional because a validator can infer the ratio when the target format declares logical dimensions. When supplied, it MUST agree with both `width / logical_width` and `height / logical_height`; it is never a substitute for the intrinsic `width` and `height` fields." - }, - "state_id": { - "type": "string", - "description": "Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one declared `states[].state_id` (semantic validators resolve it). Omit for ordinary image slots." - }, - "breakpoint_id": { - "type": "string", - "description": "Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one breakpoint declared on the selected state (semantic validators resolve it). Omit for ordinary image slots." - }, - "focal_point": { - "type": "array", - "items": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "minItems": 2, - "maxItems": 2, - "description": "Normalized `[x, y]` coordinates (0\u20131 from top-left) of the image's visual anchor. Seller-side renderers crop toward the focal point when deriving renditions across breakpoints and aspect ratios; absent, cropping falls back to center-weighted defaults." - }, - "format": { - "type": "string", - "description": "Image file format (jpg, png, gif, webp, etc.)" - }, - "alt_text": { - "type": "string", - "description": "Alternative text for accessibility", - "x-accessibility": true - }, - "provenance": { - "$ref": "#/definitions/core_provenance", - "description": "Provenance metadata for this asset, overrides manifest-level provenance" - } - }, - "required": [ - "asset_type", - "url", - "width", - "height" - ], - "additionalProperties": true - }, - "core_brand_ref": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Brand Reference", - "description": "Reference to a brand by domain and optional brand_id. The domain hosts /.well-known/brand.json or is registered in the brand registry. For single-brand domains, brand_id can be omitted. For house-of-brands domains, brand_id identifies the specific brand. For creative production, brand.json is the canonical source of master brand identity (logo, palette, fonts, voice, and visual guidelines), subject only to the supported per-call fields in brand_kit_override. Catalogs supply product or item payload; catalog item asset groups, including property- or franchise-level logos, are item identity and do not override these master brand fields.", - "type": "object", - "properties": { - "domain": { - "type": "string", - "description": "Domain where /.well-known/brand.json is hosted, or the brand's operating domain", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" - }, - "brand_id": { - "$ref": "#/definitions/core_brand_id", - "description": "Brand identifier within the house portfolio. Optional for single-brand domains." - }, - "countries": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[A-Z]{2}$" - }, - "minItems": 1, - "uniqueItems": true, - "description": "Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting." - }, - "industries": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." - }, - "data_subject_contestation": { - "type": "object", - "description": "Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand \u2192 house \u2192 missing.", - "properties": { - "url": { - "type": "string", - "format": "uri", - "pattern": "^https://" - }, - "email": { - "type": "string", - "format": "email" - }, - "languages": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "anyOf": [ - { - "required": [ - "url" - ] - }, - { - "required": [ - "email" - ] - } - ], - "additionalProperties": false - }, - "brand_kit_override": { - "type": "object", - "description": "Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call \u2014 e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` \u2014 the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently \u2014 when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently \u2014 a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload.", - "properties": { - "logo": { - "$ref": "#/definitions/core_assets_image_asset", - "description": "Override logo asset." - }, - "colors": { - "type": "object", - "description": "Override brand colors (hex strings).", - "properties": { - "primary": { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - }, - "secondary": { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - }, - "accent": { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - } - }, - "additionalProperties": true - }, - "voice": { - "type": "string", - "description": "Override brand-voice description for surface-composed text/audio output." - }, - "tagline": { - "type": "string", - "description": "Override tagline." - } - }, - "additionalProperties": true - } - }, - "required": [ - "domain" - ], - "additionalProperties": false, - "examples": [ - { - "domain": "nova-brands.com", - "brand_id": "spark" - }, - { - "domain": "nova-brands.com", - "brand_id": "glow" - }, - { - "domain": "acme-corp.com" - }, - { - "domain": "nova-athletics.example", - "countries": [ - "NL" - ] - } - ] - }, - "core_identity_relying_party": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Identity Relying Party", - "type": "object", - "description": "A verified-identity relying party an entity or brand operates. Used for attestation provenance in TMP Identity Match (the buyer checks a forwarded attestation's relying_party_id against the owner's published list). Issuer-agnostic; World ID is the first issuer.", - "properties": { - "issuer": { - "$ref": "#/definitions/core_brand_ref", - "description": "Identity issuer / attestation authority, referenced as a vendor BrandRef (e.g. {\"domain\": \"world.org\"}) \u2014 the same vendor-reference shape AdCP uses for measurement and signals vendors. The issuer's canonical domain is the anchor; it need not host a brand.json, but if it does, that is where its verifier metadata (scheme versions, verify endpoint, JWKs) lives. Issuer-agnostic: World ID, ISO 18013-5 mDL, and W3C-VC issuers all reference by domain. The relying party is namespaced by the issuer \u2014 identity is the tuple (issuer.domain, issuer.brand_id, relying_party_id), mirroring (vendor.domain, vendor.brand_id, metric_id)." - }, - "scheme": { - "type": "string", - "description": "Proof scheme and version, e.g. \"world_id_v4\"." - }, - "relying_party_id": { - "type": "string", - "x-entity": "identity_relying_party", - "description": "The relying-party id registered with the issuer (and, for on-chain issuers like World ID, with the issuer's registry). One entity may operate many relying parties (scope=entity vs scope=property); this is the attestation's audience / linkability boundary, not an entity identifier." - }, - "scope": { - "type": "string", - "enum": [ - "entity", - "property" - ], - "description": "Whether this relying_party_id is shared across the entity's properties (entity \u2192 a within-entity unique-human graph) or scoped to a single property (property \u2192 per-property pseudonyms, unlinkable across the entity)." - } - }, - "required": [ - "issuer", - "relying_party_id" - ], - "additionalProperties": false - }, - "trust_v1_trust": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "trust.json", - "x-status": "experimental", - "description": "An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json \u2014 never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md.", - "definitions": { - "domain": { - "type": "string", - "pattern": "^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$", - "description": "A lowercase DNS host name (internationalized names in punycode)." - }, - "https_url": { - "type": "string", - "format": "uri", - "pattern": "^https://", - "description": "An HTTPS URL. Every such URL in this document is untrusted input and, when fetched, a fetch target: the pattern is not an SSRF check. Verifiers MUST apply their own SSRF validation (no userinfo, no private or link-local addresses, no redirects) and enforce body-size and item-count limits." - }, - "namespaced_token": { - "type": "string", - "pattern": "^[a-z0-9-]+:[a-z0-9_.-]+$", - "description": "A ':' token. Each namespace is defined by the protocol that owns it (e.g. 'adcp:' by AdCP). Consumers MUST ignore tokens in namespaces they do not implement." - }, - "role": { - "description": "Functional role of an agent. Tokens in the adcp namespace MUST be one of the AdCP agent types.", - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/namespaced_token" - }, - { - "not": { - "pattern": "^adcp:" - } - } - ] - }, - { - "type": "string", - "enum": [ - "adcp:brand", - "adcp:rights", - "adcp:measurement", - "adcp:governance", - "adcp:creative", - "adcp:sales", - "adcp:buying", - "adcp:signals" - ] - } - ] - }, - "scope": { - "description": "An activity a grant covers. Tokens in the adcp namespace MUST be one of the AdCP scopes (brand.json authorized_operator scopes plus 'adcp:brand_identity'). The bare token '*' covers every scope in every namespace.", - "anyOf": [ - { - "type": "string", - "const": "*" - }, - { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/namespaced_token" - }, - { - "not": { - "pattern": "^adcp:" - } - } - ] - }, - { - "type": "string", - "enum": [ - "adcp:all", - "adcp:media_buying", - "adcp:creative_generation", - "adcp:rights_clearance", - "adcp:governance", - "adcp:measurement", - "adcp:agent_operations", - "adcp:brand_identity" - ] - } - ] - }, - "grant_source": { - "description": "Where the grantor publishes the grant being acknowledged. Tokens in the adcp namespace MUST be a grant source AdCP defines: 'adcp:adagents' (the grantor's /.well-known/adagents.json authorized_agents[]) or 'adcp:trust' (the grantor's /.well-known/trust.json grants[]).", - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/namespaced_token" - }, - { - "not": { - "pattern": "^adcp:" - } - } - ] - }, - { - "type": "string", - "enum": [ - "adcp:adagents", - "adcp:trust" - ] - } - ] - }, - "open_profiles": { - "type": "object", - "description": "Per-protocol data keyed by namespace. Consumers MUST ignore namespaces they do not implement. Nothing in profiles is key material or authority for another namespace.", - "propertyNames": { - "pattern": "^[a-z0-9-]+$" - }, - "additionalProperties": { - "type": "object" - } - }, - "agent": { - "type": "object", - "description": "An agent this organization runs, identified by the canonical origin of `url`. One agent per origin. Its signing keys are published only in the key directory at that origin (`/.well-known/http-message-signatures-directory`, Web Bot Auth), never in this document.", - "properties": { - "url": { - "$ref": "#/definitions/trust_v1_trust/definitions/https_url", - "description": "Agent endpoint URL, which may carry a path. Its host eTLD+1 MUST equal the eTLD+1 of the organization this record speaks for." - }, - "roles": { - "type": "array", - "description": "What this agent does, as namespaced roles.", - "items": { - "$ref": "#/definitions/trust_v1_trust/definitions/role" - }, - "minItems": 1, - "uniqueItems": true - }, - "protocols": { - "type": "array", - "description": "Transport protocols the endpoint speaks. Informational.", - "items": { - "type": "string", - "enum": [ - "mcp", - "a2a" - ] - }, - "uniqueItems": true - }, - "id": { - "type": "string", - "description": "Operator-chosen identifier for logging and routing. Not an identity input." - }, - "description": { - "type": "string" - }, - "countries": { - "type": "array", - "description": "ISO 3166-1 alpha-2 country codes where this agent operates. Omit for global.", - "items": { - "type": "string", - "pattern": "^[A-Z]{2}$" - } - }, - "profiles": { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry.", - "properties": { - "available_uses": { - "type": "array", - "items": { - "$ref": "#/definitions/enums_right_use" - }, - "minItems": 1 - }, - "right_types": { - "type": "array", - "items": { - "$ref": "#/definitions/enums_right_type" - }, - "minItems": 1 - }, - "onboarding": { - "$ref": "#/definitions/core_buyer_onboarding_posture" - } - }, - "additionalProperties": false - } - } - } - ] - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "url", - "roles" - ], - "additionalProperties": false - }, - "granted_agent": { - "type": "object", - "description": "An agent a grant is limited to, matched by canonical origin. Grants never carry keys.", - "properties": { - "url": { - "$ref": "#/definitions/trust_v1_trust/definitions/https_url" - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "grant": { - "type": "object", - "description": "Authority this organization delegates: 'the grantee (optionally only these agents) may act for us, for these scopes, in these countries, during this window.' Non-transitive: a grantee never extends a grant it received. It MAY make its own grant to one intermediary for scopes it holds, and effective authority is the intersection along that chain (brands, countries, scopes, earliest valid_until); the intermediary cannot grant further. In the AdCP profile this is the demand-side grant (#6033, DR-0025): relying parties evaluate it at sync_accounts from the brand's own record. When this document is fetched successfully, brand.json authorized_operators[] is not consulted for the same grantor.", - "properties": { - "grantee": { - "$ref": "#/definitions/trust_v1_trust/definitions/domain", - "description": "Domain of the organization receiving the grant. Its agents resolve to this domain through its own trust.json." - }, - "agents": { - "type": "array", - "description": "Optional narrowing to specific agents the grantee runs. When absent, any agent that resolves to the grantee acts under the grant.", - "items": { - "$ref": "#/definitions/trust_v1_trust/definitions/granted_agent" - }, - "minItems": 1 - }, - "scopes": { - "type": "array", - "items": { - "$ref": "#/definitions/trust_v1_trust/definitions/scope" - }, - "minItems": 1, - "uniqueItems": true - }, - "countries": { - "type": "array", - "description": "ISO 3166-1 alpha-2 country codes where the grant applies. Omit for global.", - "items": { - "type": "string", - "pattern": "^[A-Z]{2}$" - } - }, - "valid_from": { - "type": "string", - "format": "date-time", - "description": "Consumers MUST ignore the grant before this time." - }, - "valid_until": { - "type": "string", - "format": "date-time", - "description": "Consumers MUST treat the grant as inactive at or after this time, on every check." - }, - "profiles": { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for a grant.", - "properties": { - "brands": { - "type": "array", - "description": "Brand IDs from this organization's brand.json that the grant covers. ['*'] or omitted means every brand the grantor owns.", - "items": { - "type": "string", - "pattern": "^([a-z0-9_]+|\\*)$" - }, - "minItems": 1 - } - }, - "additionalProperties": false - } - } - } - ] - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "grantee", - "scopes" - ], - "additionalProperties": false - }, - "acknowledgement": { - "type": "object", - "description": "This organization's acceptance of a grant another organization published: 'we accept the grant `grantor` gave to `agent_url`, as published via `via`.' Terms are read ONLY from the grantor's document and are never restated here. Not needed when the grantor has the same eTLD+1 as the host serving this document. In the AdCP profile with via 'adcp:adagents' this is intended to supersede brand.json properties[] entries whose relationship is direct, delegated, or ad_network in a future release; brand.json is unchanged in 3.3.", - "properties": { - "grantor": { - "$ref": "#/definitions/trust_v1_trust/definitions/domain", - "description": "The organization that published the grant." - }, - "agent_url": { - "$ref": "#/definitions/trust_v1_trust/definitions/https_url", - "description": "The granted agent. MUST match an agents[] entry in this same document by canonical origin; an acknowledgement for an agent this organization does not run is invalid and MUST be ignored." - }, - "via": { - "$ref": "#/definitions/trust_v1_trust/definitions/grant_source", - "description": "Where the grant is published. For 'adcp:adagents', verifiers resolve https://{grantor}/.well-known/adagents.json (following authoritative_location and managed-network rules) and require an authorized_agents[] entry whose canonical url equals agent_url and that reaches the grantor under the reachable-publisher rule. One acknowledgement covers every entry for that agent URL, whatever its authorization_type. The adagents.json comparison is by canonical URL, as that file defines (see docs/reference/url-canonicalization.mdx), not by origin." - }, - "profiles": { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for an acknowledgement.", - "properties": { - "delegation_type": { - "type": "string", - "enum": [ - "direct", - "delegated", - "ad_network" - ], - "description": "Optional operator-side assertion of the commercial relationship, for sellers.json-style cross-checking. When present it MUST equal the publisher's delegation_type; a mismatch fails the acknowledgement (state 'mismatch'). Omit to defer entirely to the publisher." - } - }, - "additionalProperties": false - } - } - } - ] - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "grantor", - "agent_url", - "via" - ], - "additionalProperties": false - }, - "record_profiles": { - "allOf": [ - { - "$ref": "#/definitions/trust_v1_trust/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for the whole record.", - "properties": { - "identity_relying_parties": { - "type": "array", - "description": "Verified-identity relying parties this organization operates (TMP Identity Match attestation provenance). Intended to move here from brand.json in a future release; brand.json is unchanged in 3.3. Experimental.", - "items": { - "$ref": "#/definitions/core_identity_relying_party" - } - }, - "onboarding": { - "$ref": "#/definitions/core_buyer_onboarding_posture" - } - }, - "additionalProperties": false - } - } - } - ] - } - }, - "oneOf": [ - { - "type": "object", - "title": "Authoritative Location Redirect", - "description": "Pointer for hosted records (a managed network, a platform, AAO hosting). The organization is still the host that served this pointer. Verifiers follow at most one hop, with zero HTTP redirects, and MUST NOT follow a second pointer. Hosts serving records on an organization's behalf MUST only serve records the organization controls.", - "properties": { - "$schema": { - "type": "string" - }, - "authoritative_location": { - "$ref": "#/definitions/trust_v1_trust/definitions/https_url", - "description": "HTTPS URL of the authoritative trust.json for this host." - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "authoritative_location" - ], - "additionalProperties": false - }, - { - "type": "object", - "title": "Trust Record", - "properties": { - "$schema": { - "type": "string" - }, - "version": { - "type": "string" - }, - "last_updated": { - "type": "string", - "format": "date-time" - }, - "agents": { - "type": "array", - "description": "Agents this organization runs.", - "items": { - "$ref": "#/definitions/trust_v1_trust/definitions/agent" - } - }, - "grants": { - "type": "array", - "description": "Authority this organization delegates to others.", - "items": { - "$ref": "#/definitions/trust_v1_trust/definitions/grant" - } - }, - "exclusive_grants": { - "type": "boolean", - "default": false, - "description": "When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - }, - "acknowledgements": { - "type": "array", - "description": "Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small.", - "items": { - "$ref": "#/definitions/trust_v1_trust/definitions/acknowledgement" - } - }, - "acknowledgements_url": { - "$ref": "#/definitions/trust_v1_trust/definitions/https_url", - "description": "Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - }, - "profiles": { - "$ref": "#/definitions/trust_v1_trust/definitions/record_profiles" - }, - "ext": { - "type": "object", - "description": "Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority.", - "additionalProperties": { - "type": "object" - } - } - }, - "anyOf": [ - { - "required": [ - "agents" - ] - }, - { - "required": [ - "grants" - ] - }, - { - "required": [ - "acknowledgements" - ] - }, - { - "required": [ - "acknowledgements_url" - ] - }, - { - "required": [ - "profiles" - ] - } - ], - "not": { - "required": [ - "acknowledgements", - "acknowledgements_url" - ] - }, - "additionalProperties": false - } - ] - } - }, - "anyOf": [ - { - "$ref": "#/definitions/core_agent_onboarding" - }, - { - "$ref": "#/definitions/core_buyer_onboarding_posture" - }, - { - "$ref": "#/definitions/core_principal_relationship" - }, - { - "$ref": "#/definitions/trust_v1_trust" - } - ] -} diff --git a/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json b/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json deleted file mode 100644 index 885f1fb62..000000000 --- a/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/agent-onboarding-requirement.json", - "title": "Agent Onboarding Requirement", - "x-status": "experimental", - "description": "One requirement an onboarding mode imposes, discriminated by kind. Only aao_verified, storyboard, and experimental_feature carry fields; other kinds are the kind alone. The requirement describes the gate the publishing party applies on its own side; it creates no conformance obligation on the counterparty.", - "type": "object", - "properties": { - "kind": { - "$ref": "/schemas/enums/agent-onboarding-requirement-kind.json" - }, - "role": { - "type": "string", - "description": "aao_verified: the badge role required, such as media-buy." - }, - "verification_modes": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "aao_verified: accepted verification modes, as the badge token names them." - }, - "specialisms": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "aao_verified: specialisms the badge must include." - }, - "adcp_version": { - "type": "string", - "description": "aao_verified: the AdCP release the badge must cover." - }, - "id": { - "type": "string", - "description": "storyboard: the storyboard or specialism id. experimental_feature: the feature id." - }, - "ext": { - "$ref": "/schemas/core/ext.json" - } - }, - "required": ["kind"], - "additionalProperties": true -} diff --git a/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json b/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json deleted file mode 100644 index 2ab410cea..000000000 --- a/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/agent-onboarding-tier.json", - "title": "Agent Onboarding Tier", - "x-status": "experimental", - "description": "The onboarding mode for one billing party, or for sandbox access, with its requirements and an optional page of its own.", - "type": "object", - "properties": { - "mode": { - "$ref": "/schemas/enums/agent-onboarding-mode.json" - }, - "url": { - "type": "string", - "format": "uri", - "description": "Where a human starts for this mode. Defaults to the posture's top-level url. Untrusted: surface it, never fetch or execute it." - }, - "requirements": { - "type": "array", - "items": { "$ref": "/schemas/core/agent-onboarding-requirement.json" }, - "description": "What the counterparty must satisfy for this mode." - }, - "ext": { - "$ref": "/schemas/core/ext.json" - } - }, - "required": ["mode"], - "additionalProperties": true -} diff --git a/src/adcp/signing/_identity_schemas/core/agent-onboarding.json b/src/adcp/signing/_identity_schemas/core/agent-onboarding.json deleted file mode 100644 index 309f840e2..000000000 --- a/src/adcp/signing/_identity_schemas/core/agent-onboarding.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/agent-onboarding.json", - "title": "Agent Onboarding Posture", - "x-status": "experimental", - "description": "How a seller onboards buyer agents: a mode per billing party and for sandbox, with requirements, and where a human starts. Seller-wide and identical for every caller, served on the unauthenticated get_adcp_capabilities fetch, and absent from error payloads, so it cannot act as a per-agent onboarding oracle. Its content is advisory; it changes no error code and no oracle clamp. An absent key means undeclared, never invite_only. See specs/agent-identity-3-3.md R7 (#8113).", - "type": "object", - "properties": { - "sandbox": { - "$ref": "/schemas/core/agent-onboarding-tier.json", - "description": "Onboarding for sandbox accounts. Valid only when account.sandbox is true. Sandbox carries no spend, so open here grants identity and sandbox access only." - }, - "billing": { - "type": "object", - "description": "Onboarding per billing party, keyed by billing-party values. Keys MUST be a subset of account.supported_billing. Under open, spend needs an existing seller-side billing relationship with the billed party: the operator domain under operator, the brand's domain under advertiser.", - "propertyNames": { "$ref": "/schemas/enums/billing-party.json" }, - "additionalProperties": { "$ref": "/schemas/core/agent-onboarding-tier.json" } - }, - "url": { - "type": "string", - "format": "uri", - "description": "Where a human at the buyer starts when a mode has no url of its own. Untrusted: surface it, never fetch or execute it." - }, - "documentation_url": { - "type": "string", - "format": "uri", - "description": "The seller's onboarding and integration documentation. Untrusted, like url." - }, - "message": { - "type": "string", - "description": "Human-readable summary of the process and what to have ready. The fallback whenever a reader does not understand a requirement." - }, - "ext": { - "$ref": "/schemas/core/ext.json" - } - }, - "additionalProperties": true -} diff --git a/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json b/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json deleted file mode 100644 index 09943ffac..000000000 --- a/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/buyer-onboarding-posture.json", - "title": "Buyer Onboarding Posture", - "x-status": "experimental", - "description": "How a buying agent considers sellers: a mode, the requirements a seller must meet, and where a seller's human starts. Published in the buyer's trust.json agent entry (profiles.adcp.onboarding), or as a record-level default (record_profiles.adcp.onboarding). Public and identical for every reader. A buyer that serves get_adcp_capabilities is authoritative there; trust.json may mirror it. See specs/agent-identity-3-3.md R7 (#8114).", - "type": "object", - "properties": { - "mode": { - "$ref": "/schemas/enums/buyer-onboarding-mode.json" - }, - "requirements": { - "type": "array", - "items": { "$ref": "/schemas/core/agent-onboarding-requirement.json" }, - "description": "What a seller must satisfy. Uses the kinds that apply when a buyer gates a seller (aao_verified, storyboard, sandbox, experimental_feature); others are ignored." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Where a seller's human starts. Untrusted: surface it, never fetch or execute it." - }, - "documentation_url": { - "type": "string", - "format": "uri", - "description": "The buyer's documentation for sellers. Untrusted, like url." - }, - "message": { - "type": "string", - "description": "Human-readable summary; the fallback whenever a reader does not understand a requirement." - }, - "ext": { - "$ref": "/schemas/core/ext.json" - } - }, - "required": ["mode"], - "additionalProperties": true -} diff --git a/src/adcp/signing/_identity_schemas/core/identity-relying-party.json b/src/adcp/signing/_identity_schemas/core/identity-relying-party.json deleted file mode 100644 index c0492341d..000000000 --- a/src/adcp/signing/_identity_schemas/core/identity-relying-party.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/identity-relying-party.json", - "title": "Identity Relying Party", - "type": "object", - "description": "A verified-identity relying party an entity or brand operates. Used for attestation provenance in TMP Identity Match (the buyer checks a forwarded attestation's relying_party_id against the owner's published list). Issuer-agnostic; World ID is the first issuer.", - "properties": { - "issuer": { - "$ref": "/schemas/core/brand-ref.json", - "description": "Identity issuer / attestation authority, referenced as a vendor BrandRef (e.g. {\"domain\": \"world.org\"}) — the same vendor-reference shape AdCP uses for measurement and signals vendors. The issuer's canonical domain is the anchor; it need not host a brand.json, but if it does, that is where its verifier metadata (scheme versions, verify endpoint, JWKs) lives. Issuer-agnostic: World ID, ISO 18013-5 mDL, and W3C-VC issuers all reference by domain. The relying party is namespaced by the issuer — identity is the tuple (issuer.domain, issuer.brand_id, relying_party_id), mirroring (vendor.domain, vendor.brand_id, metric_id)." - }, - "scheme": { - "type": "string", - "description": "Proof scheme and version, e.g. \"world_id_v4\"." - }, - "relying_party_id": { - "type": "string", - "x-entity": "identity_relying_party", - "description": "The relying-party id registered with the issuer (and, for on-chain issuers like World ID, with the issuer's registry). One entity may operate many relying parties (scope=entity vs scope=property); this is the attestation's audience / linkability boundary, not an entity identifier." - }, - "scope": { - "type": "string", - "enum": [ - "entity", - "property" - ], - "description": "Whether this relying_party_id is shared across the entity's properties (entity → a within-entity unique-human graph) or scoped to a single property (property → per-property pseudonyms, unlinkable across the entity)." - } - }, - "required": [ - "issuer", - "relying_party_id" - ], - "additionalProperties": false -} diff --git a/src/adcp/signing/_identity_schemas/core/principal-relationship.json b/src/adcp/signing/_identity_schemas/core/principal-relationship.json deleted file mode 100644 index bdace571c..000000000 --- a/src/adcp/signing/_identity_schemas/core/principal-relationship.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/principal-relationship.json", - "title": "Principal Relationship", - "x-status": "experimental", - "description": "The seller's commercial relationship with the authenticated caller, returned by get_principal only to that caller. Present when a relationship or an application exists; omitted when the seller has no relationship with the caller and no application in progress, which is the identity-established state of a verified signer the seller has not bound. Because it is returned only to the authenticated caller, it never acts as an onboarding oracle. See specs/agent-identity-3-3.md R8.", - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["pending", "active", "suspended", "blocked"], - "enumDescriptions": { - "pending": "An application or approval is in progress. setup, when present, carries the caller's next step.", - "active": "The relationship is in good standing.", - "suspended": "The relationship is temporarily paused; calls that need it fail with AGENT_SUSPENDED.", - "blocked": "The relationship is permanently denied; calls that need it fail with AGENT_BLOCKED." - }, - "description": "Current state of the relationship." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "When the relationship lapses unless renewed. Absent means no scheduled expiry. principal.changed fires with reason relationship_expiring at a seller-declared lead time before this instant." - }, - "setup": { - "type": "object", - "description": "The caller's next step while status is pending, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide.", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "URL where a human at the buyer completes the step. Untrusted: surface it, never fetch or execute it." - }, - "message": { - "type": "string", - "description": "Human-readable description of what is needed." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "When this step expires." - } - }, - "required": ["message"], - "additionalProperties": true - }, - "ext": { - "$ref": "/schemas/core/ext.json" - } - }, - "required": ["status"], - "additionalProperties": true -} diff --git a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json b/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json deleted file mode 100644 index 5bccc6d15..000000000 --- a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/enums/agent-onboarding-mode.json", - "title": "Agent Onboarding Mode", - "x-status": "experimental", - "description": "How a seller onboards buyer agents for one billing party or for sandbox access. Absent means undeclared, never invite_only.", - "type": "string", - "enum": ["open", "application", "invite_only"], - "enumDescriptions": { - "open": "A buyer agent that meets every listed requirement and whose identity the seller can establish from the agent's published keys (the Web Bot Auth profile) may proceed without prior contact. open binds identity, not spend: spend still needs an existing billing relationship with the billed party, and a brand outside the buyer's operator domain needs a grant. A seller that only maps opaque, seller-issued credentials MUST NOT declare open.", - "application": "The buyer completes an out-of-band step at url before the seller recognizes it for this mode.", - "invite_only": "The seller does not accept unsolicited buyer agents for this mode. url MAY be absent." - } -} diff --git a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-requirement-kind.json b/src/adcp/signing/_identity_schemas/enums/agent-onboarding-requirement-kind.json deleted file mode 100644 index eb6d07c43..000000000 --- a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-requirement-kind.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/enums/agent-onboarding-requirement-kind.json", - "title": "Agent Onboarding Requirement Kind", - "x-status": "experimental", - "description": "What a counterparty must satisfy before an onboarding mode applies. Each kind states the direction it applies to; a kind used in the other direction is ignored. Readers MUST treat an unknown kind as unsatisfiable by machine and fall back to the posture's message and url.", - "type": "string", - "x-extensible": true, - "enum": [ - "agent_identity", - "grant", - "billing_entity", - "contract", - "credit_application", - "aao_verified", - "storyboard", - "sandbox", - "experimental_feature" - ], - "enumDescriptions": { - "agent_identity": "Seller gating a buyer. The buyer agent's origin is listed in its operator's trust.json with the role the seller needs (adcp:buying).", - "grant": "Seller gating a buyer. A demand-side grant authorizes the buyer's operator for the account's brand (DR-0025); implicit when the operator is the brand.", - "billing_entity": "Seller gating a buyer. The buyer supplies a billing entity (core/business-entity.json) on sync_accounts.", - "contract": "Seller gating a buyer. A signed agreement exists; details at url.", - "credit_application": "Seller gating a buyer. A credit application is completed at url, or later through sync_accounts setup.url.", - "aao_verified": "Buyer gating a seller. The seller holds an AgenticAdvertising.org Verified badge; role is required, and verification_modes, specialisms, and adcp_version narrow it.", - "storyboard": "Buyer gating a seller. The seller passes the named storyboard or specialism (id).", - "sandbox": "Buyer gating a seller. The seller declares account.sandbox: true.", - "experimental_feature": "Buyer gating a seller. The seller lists the named feature id (id) in experimental_features." - } -} diff --git a/src/adcp/signing/_identity_schemas/enums/buyer-onboarding-mode.json b/src/adcp/signing/_identity_schemas/enums/buyer-onboarding-mode.json deleted file mode 100644 index 4b8cccdd8..000000000 --- a/src/adcp/signing/_identity_schemas/enums/buyer-onboarding-mode.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/enums/buyer-onboarding-mode.json", - "title": "Buyer Onboarding Mode", - "x-status": "experimental", - "description": "How a buyer considers sellers for its plans. Distinct from the seller-side agent-onboarding-mode, whose open means something else. Absent means undeclared.", - "type": "string", - "enum": ["considers_unsolicited", "application", "invite_only"], - "enumDescriptions": { - "considers_unsolicited": "A seller that meets every listed requirement is in the buyer's consideration set without prior contact; the buyer discovers and calls it. Nothing is sent to the buyer.", - "application": "The seller completes an out-of-band step at url before the buyer considers it.", - "invite_only": "The buyer does not consider unsolicited sellers. url MAY be absent." - } -} diff --git a/src/adcp/signing/_identity_schemas/trust/v1/trust.json b/src/adcp/signing/_identity_schemas/trust/v1/trust.json deleted file mode 100644 index 7c38395b4..000000000 --- a/src/adcp/signing/_identity_schemas/trust/v1/trust.json +++ /dev/null @@ -1,497 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/trust/v1/trust.json", - "title": "trust.json", - "x-status": "experimental", - "description": "An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md.", - "definitions": { - "domain": { - "type": "string", - "pattern": "^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$", - "description": "A lowercase DNS host name (internationalized names in punycode)." - }, - "https_url": { - "type": "string", - "format": "uri", - "pattern": "^https://", - "description": "An HTTPS URL. Every such URL in this document is untrusted input and, when fetched, a fetch target: the pattern is not an SSRF check. Verifiers MUST apply their own SSRF validation (no userinfo, no private or link-local addresses, no redirects) and enforce body-size and item-count limits." - }, - "namespaced_token": { - "type": "string", - "pattern": "^[a-z0-9-]+:[a-z0-9_.-]+$", - "description": "A ':' token. Each namespace is defined by the protocol that owns it (e.g. 'adcp:' by AdCP). Consumers MUST ignore tokens in namespaces they do not implement." - }, - "role": { - "description": "Functional role of an agent. Tokens in the adcp namespace MUST be one of the AdCP agent types.", - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/definitions/namespaced_token" - }, - { - "not": { - "pattern": "^adcp:" - } - } - ] - }, - { - "type": "string", - "enum": [ - "adcp:brand", - "adcp:rights", - "adcp:measurement", - "adcp:governance", - "adcp:creative", - "adcp:sales", - "adcp:buying", - "adcp:signals" - ] - } - ] - }, - "scope": { - "description": "An activity a grant covers. Tokens in the adcp namespace MUST be one of the AdCP scopes (brand.json authorized_operator scopes plus 'adcp:brand_identity'). The bare token '*' covers every scope in every namespace.", - "anyOf": [ - { - "type": "string", - "const": "*" - }, - { - "allOf": [ - { - "$ref": "#/definitions/namespaced_token" - }, - { - "not": { - "pattern": "^adcp:" - } - } - ] - }, - { - "type": "string", - "enum": [ - "adcp:all", - "adcp:media_buying", - "adcp:creative_generation", - "adcp:rights_clearance", - "adcp:governance", - "adcp:measurement", - "adcp:agent_operations", - "adcp:brand_identity" - ] - } - ] - }, - "grant_source": { - "description": "Where the grantor publishes the grant being acknowledged. Tokens in the adcp namespace MUST be a grant source AdCP defines: 'adcp:adagents' (the grantor's /.well-known/adagents.json authorized_agents[]) or 'adcp:trust' (the grantor's /.well-known/trust.json grants[]).", - "anyOf": [ - { - "allOf": [ - { - "$ref": "#/definitions/namespaced_token" - }, - { - "not": { - "pattern": "^adcp:" - } - } - ] - }, - { - "type": "string", - "enum": [ - "adcp:adagents", - "adcp:trust" - ] - } - ] - }, - "open_profiles": { - "type": "object", - "description": "Per-protocol data keyed by namespace. Consumers MUST ignore namespaces they do not implement. Nothing in profiles is key material or authority for another namespace.", - "propertyNames": { - "pattern": "^[a-z0-9-]+$" - }, - "additionalProperties": { - "type": "object" - } - }, - "agent": { - "type": "object", - "description": "An agent this organization runs, identified by the canonical origin of `url`. One agent per origin. Its signing keys are published only in the key directory at that origin (`/.well-known/http-message-signatures-directory`, Web Bot Auth), never in this document.", - "properties": { - "url": { - "$ref": "#/definitions/https_url", - "description": "Agent endpoint URL, which may carry a path. Its host eTLD+1 MUST equal the eTLD+1 of the organization this record speaks for." - }, - "roles": { - "type": "array", - "description": "What this agent does, as namespaced roles.", - "items": { - "$ref": "#/definitions/role" - }, - "minItems": 1, - "uniqueItems": true - }, - "protocols": { - "type": "array", - "description": "Transport protocols the endpoint speaks. Informational.", - "items": { - "type": "string", - "enum": [ - "mcp", - "a2a" - ] - }, - "uniqueItems": true - }, - "id": { - "type": "string", - "description": "Operator-chosen identifier for logging and routing. Not an identity input." - }, - "description": { - "type": "string" - }, - "countries": { - "type": "array", - "description": "ISO 3166-1 alpha-2 country codes where this agent operates. Omit for global.", - "items": { - "type": "string", - "pattern": "^[A-Z]{2}$" - } - }, - "profiles": { - "allOf": [ - { - "$ref": "#/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry.", - "properties": { - "available_uses": { - "type": "array", - "items": { - "$ref": "/schemas/enums/right-use.json" - }, - "minItems": 1 - }, - "right_types": { - "type": "array", - "items": { - "$ref": "/schemas/enums/right-type.json" - }, - "minItems": 1 - } - }, - "additionalProperties": false - } - } - } - ] - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "url", - "roles" - ], - "additionalProperties": false - }, - "granted_agent": { - "type": "object", - "description": "An agent a grant is limited to, matched by canonical origin. Grants never carry keys.", - "properties": { - "url": { - "$ref": "#/definitions/https_url" - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "grant": { - "type": "object", - "description": "Authority this organization delegates: 'the grantee (optionally only these agents) may act for us, for these scopes, in these countries, during this window.' Non-transitive: a grantee never extends a grant it received. It MAY make its own grant to one intermediary for scopes it holds, and effective authority is the intersection along that chain (brands, countries, scopes, earliest valid_until); the intermediary cannot grant further. In the AdCP profile this is the demand-side grant (#6033, DR-0025): relying parties evaluate it at sync_accounts from the brand's own record. When this document is fetched successfully, brand.json authorized_operators[] is not consulted for the same grantor.", - "properties": { - "grantee": { - "$ref": "#/definitions/domain", - "description": "Domain of the organization receiving the grant. Its agents resolve to this domain through its own trust.json." - }, - "agents": { - "type": "array", - "description": "Optional narrowing to specific agents the grantee runs. When absent, any agent that resolves to the grantee acts under the grant.", - "items": { - "$ref": "#/definitions/granted_agent" - }, - "minItems": 1 - }, - "scopes": { - "type": "array", - "items": { - "$ref": "#/definitions/scope" - }, - "minItems": 1, - "uniqueItems": true - }, - "countries": { - "type": "array", - "description": "ISO 3166-1 alpha-2 country codes where the grant applies. Omit for global.", - "items": { - "type": "string", - "pattern": "^[A-Z]{2}$" - } - }, - "valid_from": { - "type": "string", - "format": "date-time", - "description": "Consumers MUST ignore the grant before this time." - }, - "valid_until": { - "type": "string", - "format": "date-time", - "description": "Consumers MUST treat the grant as inactive at or after this time, on every check." - }, - "profiles": { - "allOf": [ - { - "$ref": "#/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for a grant.", - "properties": { - "brands": { - "type": "array", - "description": "Brand IDs from this organization's brand.json that the grant covers. ['*'] or omitted means every brand the grantor owns.", - "items": { - "type": "string", - "pattern": "^([a-z0-9_]+|\\*)$" - }, - "minItems": 1 - } - }, - "additionalProperties": false - } - } - } - ] - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "grantee", - "scopes" - ], - "additionalProperties": false - }, - "acknowledgement": { - "type": "object", - "description": "This organization's acceptance of a grant another organization published: 'we accept the grant `grantor` gave to `agent_url`, as published via `via`.' Terms are read ONLY from the grantor's document and are never restated here. Not needed when the grantor has the same eTLD+1 as the host serving this document. In the AdCP profile with via 'adcp:adagents' this is intended to supersede brand.json properties[] entries whose relationship is direct, delegated, or ad_network in a future release; brand.json is unchanged in 3.3.", - "properties": { - "grantor": { - "$ref": "#/definitions/domain", - "description": "The organization that published the grant." - }, - "agent_url": { - "$ref": "#/definitions/https_url", - "description": "The granted agent. MUST match an agents[] entry in this same document by canonical origin; an acknowledgement for an agent this organization does not run is invalid and MUST be ignored." - }, - "via": { - "$ref": "#/definitions/grant_source", - "description": "Where the grant is published. For 'adcp:adagents', verifiers resolve https://{grantor}/.well-known/adagents.json (following authoritative_location and managed-network rules) and require an authorized_agents[] entry whose canonical url equals agent_url and that reaches the grantor under the reachable-publisher rule. One acknowledgement covers every entry for that agent URL, whatever its authorization_type. The adagents.json comparison is by canonical URL, as that file defines (see docs/reference/url-canonicalization.mdx), not by origin." - }, - "profiles": { - "allOf": [ - { - "$ref": "#/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for an acknowledgement.", - "properties": { - "delegation_type": { - "type": "string", - "enum": [ - "direct", - "delegated", - "ad_network" - ], - "description": "Optional operator-side assertion of the commercial relationship, for sellers.json-style cross-checking. When present it MUST equal the publisher's delegation_type; a mismatch fails the acknowledgement (state 'mismatch'). Omit to defer entirely to the publisher." - } - }, - "additionalProperties": false - } - } - } - ] - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "grantor", - "agent_url", - "via" - ], - "additionalProperties": false - }, - "record_profiles": { - "allOf": [ - { - "$ref": "#/definitions/open_profiles" - }, - { - "properties": { - "adcp": { - "type": "object", - "description": "AdCP profile for the whole record.", - "properties": { - "identity_relying_parties": { - "type": "array", - "description": "Verified-identity relying parties this organization operates (TMP Identity Match attestation provenance). Intended to move here from brand.json in a future release; brand.json is unchanged in 3.3. Experimental.", - "items": { - "$ref": "/schemas/core/identity-relying-party.json" - } - } - }, - "additionalProperties": false - } - } - } - ] - } - }, - "oneOf": [ - { - "type": "object", - "title": "Authoritative Location Redirect", - "description": "Pointer for hosted records (a managed network, a platform, AAO hosting). The organization is still the host that served this pointer. Verifiers follow at most one hop, with zero HTTP redirects, and MUST NOT follow a second pointer. Hosts serving records on an organization's behalf MUST only serve records the organization controls.", - "properties": { - "$schema": { - "type": "string" - }, - "authoritative_location": { - "$ref": "#/definitions/https_url", - "description": "HTTPS URL of the authoritative trust.json for this host." - }, - "last_updated": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "authoritative_location" - ], - "additionalProperties": false - }, - { - "type": "object", - "title": "Trust Record", - "properties": { - "$schema": { - "type": "string" - }, - "version": { - "type": "string" - }, - "last_updated": { - "type": "string", - "format": "date-time" - }, - "agents": { - "type": "array", - "description": "Agents this organization runs.", - "items": { - "$ref": "#/definitions/agent" - } - }, - "grants": { - "type": "array", - "description": "Authority this organization delegates to others.", - "items": { - "$ref": "#/definitions/grant" - } - }, - "exclusive_grants": { - "type": "boolean", - "default": false, - "description": "When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - }, - "acknowledgements": { - "type": "array", - "description": "Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small.", - "items": { - "$ref": "#/definitions/acknowledgement" - } - }, - "acknowledgements_url": { - "$ref": "#/definitions/https_url", - "description": "Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - }, - "profiles": { - "$ref": "#/definitions/record_profiles" - }, - "ext": { - "type": "object", - "description": "Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority.", - "additionalProperties": { - "type": "object" - } - } - }, - "anyOf": [ - { - "required": [ - "agents" - ] - }, - { - "required": [ - "grants" - ] - }, - { - "required": [ - "acknowledgements" - ] - }, - { - "required": [ - "acknowledgements_url" - ] - }, - { - "required": [ - "profiles" - ] - } - ], - "not": { - "required": [ - "acknowledgements", - "acknowledgements_url" - ] - }, - "additionalProperties": false - } - ] -} diff --git a/src/adcp/signing/_structured_fields.py b/src/adcp/signing/_structured_fields.py deleted file mode 100644 index f43d6a352..000000000 --- a/src/adcp/signing/_structured_fields.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Reject Structured Fields duplicates before a parser overwrites their evidence. - -This grammar-aware preflight preserves item and inner-list parameter scopes. -It does not decode values or replace the profile's existing value validation; -in particular, legacy Base64URL compatibility remains the decoder's choice. -""" - -from __future__ import annotations - - -def reject_dictionary_duplicates(value: str) -> None: - """Raise ValueError for duplicate labels/parameters or ambiguous syntax.""" - if not value.isascii(): - raise ValueError("Structured Fields must be ASCII") - _DictionaryScanner(value).scan() - - -def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: - """Observe member parameters without resolving duplicates for profile choice. - - This is never a verification parser. An explicit marker observed before a - syntax failure remains a marker; quoted text and component parameters do - not become dictionary-member parameters. - """ - scanner = _DictionaryScanner(value, reject_duplicates=False) - try: - scanner.scan() - except ValueError: - # A syntax error must not erase an explicit marker already observed. - return _parameters_contain_marker(scanner.member_parameters, name, expected) - return _parameters_contain_marker(scanner.member_parameters, name, expected) - - -def _parameters_contain_marker(parameters: list[tuple[str, str]], name: str, expected: str) -> bool: - from http_sfv.item import Item - - for key, raw in parameters: - if key != name: - continue - try: - item = Item() - item.parse(raw.encode("ascii")) - except (ValueError, UnicodeError): - continue - if item.value == expected: - return True - return False - - -class _DictionaryScanner: - def __init__(self, value: str, *, reject_duplicates: bool = True) -> None: - self.value = value - self.index = 0 - self.reject_duplicates = reject_duplicates - self.member_parameters: list[tuple[str, str]] = [] - - def _ows(self, *, inner: bool = False) -> None: - whitespace = " " if inner else " \t" - while self.index < len(self.value) and self.value[self.index] in whitespace: - self.index += 1 - - def _key(self) -> str: - start = self.index - if start == len(self.value) or self.value[start] not in "abcdefghijklmnopqrstuvwxyz*": - raise ValueError("invalid Structured Field key") - self.index += 1 - while self.index < len(self.value) and self.value[self.index] in ( - "abcdefghijklmnopqrstuvwxyz0123456789_.*-" - ): - self.index += 1 - return self.value[start : self.index] - - def _bare_item(self) -> None: - if self.index == len(self.value): - raise ValueError("missing Structured Field value") - start = self.value[self.index] - if start == "%" and self.value[self.index : self.index + 2] == '%"': - # RFC 9651 Display Strings share quoted delimiter boundaries. - # Their percent encoding is validated by the value parser. - self.index += 1 - start = '"' - if start == '"': - self.index += 1 - while self.index < len(self.value): - character = self.value[self.index] - self.index += 1 - if character == '"': - return - if character == "\\": - if self.index == len(self.value) or self.value[self.index] not in '\\"': - raise ValueError("invalid Structured Field string escape") - self.index += 1 - elif not 0x20 <= ord(character) <= 0x7E: - raise ValueError("invalid Structured Field string") - raise ValueError("unterminated Structured Field string") - if start == ":": - self.index += 1 - while self.index < len(self.value): - character = self.value[self.index] - self.index += 1 - if character == ":": - return - if character not in ( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_-" - ): - raise ValueError("invalid Structured Field binary syntax") - raise ValueError("unterminated Structured Field binary") - initial = self.index - while self.index < len(self.value) and self.value[self.index] not in " \t;,()": - character = self.value[self.index] - if character in '\\"' or not 0x21 <= ord(character) <= 0x7E: - raise ValueError("invalid Structured Field value") - self.index += 1 - if self.index == initial: - raise ValueError("missing Structured Field item") - - def _parameters(self, *, member: bool = False) -> None: - seen: set[str] = set() - while self.index < len(self.value) and self.value[self.index] == ";": - self.index += 1 - self._ows(inner=True) - key = self._key() - if key in seen and self.reject_duplicates: - raise ValueError(f"duplicate Structured Field parameter {key!r}") - seen.add(key) - if self.index < len(self.value) and self.value[self.index] == "=": - self.index += 1 - start = self.index - self._bare_item() - if member: - self.member_parameters.append((key, self.value[start : self.index])) - - def _item(self, *, member: bool = False) -> None: - self._bare_item() - self._parameters(member=member) - - def _member(self) -> None: - if self.index < len(self.value) and self.value[self.index] == "(": - self.index += 1 - while True: - self._ows(inner=True) - if self.index == len(self.value): - raise ValueError("unterminated Structured Field inner list") - if self.value[self.index] == ")": - self.index += 1 - break - self._item() - if self.index == len(self.value) or self.value[self.index] not in " )": - raise ValueError("invalid Structured Field inner-list delimiter") - self._parameters(member=True) - else: - self._item(member=True) - - def scan(self) -> None: - seen: set[str] = set() - self._ows() - if self.index == len(self.value): - raise ValueError("empty Structured Field dictionary") - while self.index < len(self.value): - key = self._key() - if key in seen and self.reject_duplicates: - raise ValueError(f"duplicate Structured Field dictionary member {key!r}") - seen.add(key) - if self.index < len(self.value) and self.value[self.index] == "=": - self.index += 1 - self._member() - else: - self._parameters(member=True) - self._ows() - if self.index == len(self.value): - return - if self.value[self.index] != ",": - raise ValueError("invalid Structured Field dictionary delimiter") - self.index += 1 - self._ows() - if self.index == len(self.value): - raise ValueError("trailing Structured Field dictionary comma") diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index f90140a58..3999c8e98 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -12,7 +12,6 @@ from __future__ import annotations -import warnings from collections.abc import Iterable from contextvars import ContextVar from dataclasses import dataclass, field @@ -116,7 +115,6 @@ class SigningConfig: alg: str = ALG_ED25519 tag: str = DEFAULT_TAG signing_profile_version: SigningProfileVersion | None = None - agent_origin: str | None = None def __post_init__(self) -> None: if self.alg not in ALLOWED_ALGS: @@ -125,20 +123,6 @@ def __post_init__(self) -> None: raise ValueError("key_id must be a non-empty string") if self.signing_profile_version not in {None, "3.0", "3.1", "3.2"}: raise ValueError("signing_profile_version must be None, '3.0', '3.1', or '3.2'") - if self.agent_origin is not None: - from adcp.signing.wba import WBA_TAG, _https_origin, public_directory_key - - object.__setattr__(self, "agent_origin", _https_origin(self.agent_origin)) - if self.key_id != public_directory_key(self.private_key)["kid"]: - raise ValueError("Web Bot Auth key_id must be the public key's thumbprint") - object.__setattr__(self, "tag", WBA_TAG) - else: - warnings.warn( - "RFC 9421-profile signing is deprecated in AdCP 3.3 and remains supported " - "through 3.x; opt into Web Bot Auth with agent_origin when peers support it.", - DeprecationWarning, - stacklevel=2, - ) def __repr__(self) -> str: # Redact the private key from string representations so accidental @@ -147,7 +131,7 @@ def __repr__(self) -> str: return ( f"SigningConfig(key_id={self.key_id!r}, alg={self.alg!r}, " f"tag={self.tag!r}, signing_profile_version={self.signing_profile_version!r}, " - f"agent_origin={self.agent_origin!r}, private_key=)" + f"private_key=)" ) @@ -214,21 +198,6 @@ def operation_needs_signing( return "skip" -def require_signing_profile(capability: RequestSigning | None, signing: SigningConfig) -> None: - """Reject unsupported opt-in profiles before sending or changing credentials.""" - profiles = getattr(capability, "profiles", None) - accepted = ( - {DEFAULT_TAG} - if profiles is None - else {getattr(profile, "value", profile) for profile in profiles} - ) - if signing.tag not in accepted: - raise ValueError( - f"Seller does not advertise request-signing profile {signing.tag!r}; " - "automatic signing cannot downgrade the configured identity" - ) - - __all__ = [ "SigningConfig", "SigningDecision", diff --git a/src/adcp/signing/bindings.py b/src/adcp/signing/bindings.py deleted file mode 100644 index 8d046177e..000000000 --- a/src/adcp/signing/bindings.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Explicit origin-to-principal binding for experimental Web Bot Auth identities.""" - -from __future__ import annotations - -import threading -from typing import Protocol - -from adcp.signing.canonical import canonical_origin - - -class OriginBindingResolver(Protocol): - """Application-owned durable lookup; discovery must never write bindings.""" - - async def __call__(self, origin: str) -> str | None: - raise NotImplementedError("implement the application-owned origin binding lookup") - - -class OriginBindings: - """In-memory binding helper for one process; use a durable resolver in production. - - A principal may own several origins, but an origin maps to one principal. - Migrating a principal that already uses RFC 9421 requires explicit approval. - Once bound, the transport refuses legacy authentication at that origin. - """ - - def __init__(self) -> None: - self._bindings: dict[str, str] = {} - self._lock = threading.Lock() - - def bind( - self, - origin: str, - principal_id: str, - *, - existing_rfc9421_principal: bool = False, - migration_approved: bool = False, - ) -> None: - origin = canonical_origin(origin) - if not origin.startswith("https://") or not principal_id: - raise ValueError("binding requires an HTTPS origin and a principal ID") - if existing_rfc9421_principal and not migration_approved: - raise ValueError("binding an existing RFC 9421 principal requires explicit approval") - with self._lock: - existing = self._bindings.get(origin) - if existing is not None and existing != principal_id: - raise ValueError("an origin cannot be rebound to another principal") - self._bindings[origin] = principal_id - - async def __call__(self, origin: str) -> str | None: - with self._lock: - return self._bindings.get(canonical_origin(origin)) - - -__all__ = ["OriginBindingResolver", "OriginBindings"] diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index 202f8400a..69b48d2cb 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -17,7 +17,6 @@ import idna from adcp.signing._idna_canonicalize import canonicalize_host -from adcp.signing._structured_fields import reject_dictionary_duplicates #: Spec code for an authority the AdCP profile requires be rejected rather than #: canonicalized. Declared here rather than imported from ``errors`` so this @@ -71,7 +70,6 @@ def parse_signature_input_header(header_value: str) -> dict[str, SignatureInputL `sig1=(...);..., sig2=(...);...`. The AdCP profile mandates that verifiers process exactly one label (conventionally `sig1`) and ignore others. """ - reject_dictionary_duplicates(header_value) labels: dict[str, SignatureInputLabel] = {} for entry in split_structured_field(header_value, ","): entry = entry.strip() @@ -152,19 +150,6 @@ def canonicalize_authority(url: str) -> str: return _canon_authority(parts.netloc, parts.scheme.lower()) -def canonical_origin(url: str) -> str: - """Return scheme and authority after AdCP URL canonicalization steps 1–4. - - Paths, queries and fragments do not participate in an origin identity. - Only absolute HTTP(S) URLs have an origin in this SDK. - """ - parts = _split_or_reject(url) - scheme = parts.scheme.lower() - if scheme not in {"http", "https"} or not parts.netloc: - raise ValueError("an origin requires an absolute HTTP(S) URL") - return f"{scheme}://{_canon_authority(parts.netloc, scheme)}" - - def _split_or_reject(url: str) -> SplitResult: """`urlsplit`, with its bare refusals normalized onto the typed error. diff --git a/src/adcp/signing/client.py b/src/adcp/signing/client.py index 1bb29071d..bdfa3d8bb 100644 --- a/src/adcp/signing/client.py +++ b/src/adcp/signing/client.py @@ -61,7 +61,6 @@ SigningConfig, current_operation, operation_needs_signing, - require_signing_profile, signing_profile_for_adcp_version, ) from adcp.signing.signer import sign_request @@ -161,9 +160,6 @@ def install_signing_event_hook( signing_profile_version = signing.signing_profile_version elif adcp_version is not None: signing_profile_version = signing_profile_for_adcp_version(adcp_version) - elif signing.agent_origin is not None: - # WBA fixes its own encoding and does not use the legacy wire version. - signing_profile_version = "3.2" else: raise ValueError( "install_signing_event_hook requires adcp_version when " @@ -218,7 +214,6 @@ async def _hook(request: httpx.Request) -> None: decision = operation_needs_signing(capability, operation) if decision == "skip": return - require_signing_profile(capability, signing) covers_policy: str | None = None if capability is not None and capability.covers_content_digest is not None: @@ -243,7 +238,6 @@ async def _hook(request: httpx.Request) -> None: alg=signing.alg, cover_content_digest=cover_digest, tag=signing.tag, - agent_origin=signing.agent_origin, signing_profile_version=signing_profile_version, ) # pop-then-set so our values are authoritative even if an diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index 075bec789..437931ac2 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -25,7 +25,6 @@ encode_dss_signature, ) -from adcp.signing._structured_fields import reject_dictionary_duplicates from adcp.signing.canonical import split_structured_field PublicKey = ed25519.Ed25519PublicKey | ec.EllipticCurvePublicKey @@ -228,7 +227,6 @@ def extract_signature_bytes( permits the historical Base64URL spelling; 3.2 callers disable that fallback so malformed Structured Fields fail at verifier step 1. """ - reject_dictionary_duplicates(signature_header) for entry in split_structured_field(signature_header, ","): entry = entry.strip() if not entry: diff --git a/src/adcp/signing/revocation.py b/src/adcp/signing/revocation.py index c9e08c0f1..44d6a84ba 100644 --- a/src/adcp/signing/revocation.py +++ b/src/adcp/signing/revocation.py @@ -19,68 +19,6 @@ class RevocationChecker(Protocol): def __call__(self, keyid: str) -> bool: ... -@dataclass(frozen=True) -class RevocationDetails: - """Optional RFC 5280 reason and the earliest known invalidity date.""" - - reason: str | None = None - invalidity_date: datetime | None = None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> RevocationDetails: - reason = data.get("reason") - if reason is not None and ( - not isinstance(reason, str) - or reason - not in { - "unspecified", - "keyCompromise", - "cACompromise", - "affiliationChanged", - "superseded", - "cessationOfOperation", - "certificateHold", - "removeFromCRL", - "privilegeWithdrawn", - "aACompromise", - } - ): - raise ValueError("unknown RFC 5280 revocation reason") - raw = data.get("invalidity_date") - date = None - if raw is not None: - if not isinstance(raw, str): - raise ValueError("invalidity_date must be an RFC 3339 timestamp") - date = datetime.fromisoformat(raw.replace("Z", "+00:00")) - if date.tzinfo is None: - raise ValueError("invalidity_date must carry a timezone") - return cls(reason=reason, invalidity_date=date) - - def merge(self, newer: RevocationDetails) -> RevocationDetails: - dates = [date for date in (self.invalidity_date, newer.invalidity_date) if date is not None] - undated_compromise = any( - detail.reason == "keyCompromise" and detail.invalidity_date is None - for detail in (self, newer) - ) - reason = ( - "keyCompromise" - if "keyCompromise" in {self.reason, newer.reason} - else self.reason or newer.reason - ) - # An undated compromise exposes all prior receipts. Attaching a later - # date would narrow that previously observed exposure window. - return RevocationDetails( - reason=reason, - invalidity_date=None if undated_compromise or not dates else min(dates), - ) - - def exposes(self, received_at: datetime) -> bool: - """Compromise exposure uses seller receipt time, never signature creation.""" - return self.reason == "keyCompromise" and ( - self.invalidity_date is None or received_at >= self.invalidity_date - ) - - @dataclass class RevocationList: """In-memory representation of the revocation list snapshot.""" @@ -90,16 +28,6 @@ class RevocationList: next_update: str revoked_kids: frozenset[str] = field(default_factory=frozenset) revoked_jtis: frozenset[str] = field(default_factory=frozenset) - revocation_details: dict[str, RevocationDetails] = field(default_factory=dict) - - def retain(self, previous: RevocationList) -> None: - """Never withdraw revocations or weaken previously observed details.""" - if self.issuer != previous.issuer: - raise ValueError("cannot merge revocations from different issuers") - self.revoked_kids |= previous.revoked_kids - self.revoked_jtis |= previous.revoked_jtis - for kid, detail in previous.revocation_details.items(): - self.revocation_details[kid] = detail.merge(self.revocation_details.get(kid, detail)) def is_revoked(self, keyid: str) -> bool: return keyid in self.revoked_kids @@ -122,25 +50,10 @@ def is_stale(self, now: datetime) -> bool: @classmethod def from_dict(cls, data: dict[str, Any]) -> RevocationList: - kids = data.get("revoked_kids", []) - jtis = data.get("revoked_jtis", []) - if any( - not isinstance(values, (list, tuple)) or any(not isinstance(v, str) for v in values) - for values in (kids, jtis) - ): - raise ValueError("revocation identifiers must be arrays of strings") - details = data.get("revocation_details", {}) - if not isinstance(details, dict) or any( - kid not in kids or not isinstance(detail, dict) for kid, detail in details.items() - ): - raise ValueError("revocation_details must describe revoked_kids only") return cls( issuer=data["issuer"], updated=data["updated"], next_update=data["next_update"], revoked_kids=frozenset(data.get("revoked_kids", ())), revoked_jtis=frozenset(data.get("revoked_jtis", ())), - revocation_details={ - kid: RevocationDetails.from_dict(detail) for kid, detail in details.items() - }, ) diff --git a/src/adcp/signing/revocation_fetcher.py b/src/adcp/signing/revocation_fetcher.py index 638f451e0..f66e8325c 100644 --- a/src/adcp/signing/revocation_fetcher.py +++ b/src/adcp/signing/revocation_fetcher.py @@ -497,8 +497,6 @@ class _CheckerState: _last_polling_interval_seconds: float | None _last_refresh_attempt: float | None _grace_multiplier: float - _history_admission: Callable[[RevocationList], bool] | None - _history_overflowed: bool def _init_state(self) -> None: self._current_list = None @@ -507,12 +505,6 @@ def _init_state(self) -> None: self._last_successful_refresh = None self._last_polling_interval_seconds = None self._last_refresh_attempt = None - self._history_overflowed = False - - @property - def has_verified_list(self) -> bool: - """Whether authenticated revocation evidence must be retained, even if stale.""" - return self._current_list is not None or self._history_overflowed def _handle_not_modified(self, *, now_mono: float) -> None: """Record a successful conditional request without changing signed freshness. @@ -530,19 +522,6 @@ def _commit( now_mono: float, ) -> None: """Commit a validated list to the cache.""" - self._check_history() - if self._current_list is not None: - revocation_list.retain(self._current_list) - try: - admitted = self._history_admission is None or self._history_admission(revocation_list) - except Exception as exc: - self._history_overflowed = True - raise RevocationListFreshnessError("revocation history admission failed") from exc - if not admitted: - # Forgetting refused revocations would allow a later list to omit - # them. Permanently close this checker rather than weakening facts. - self._history_overflowed = True - raise RevocationListFreshnessError("revocation history admission capacity exceeded") self._current_list = revocation_list self._current_etag = result.etag # Sanitize again at write-side: custom fetcher impls may not have @@ -551,21 +530,10 @@ def _commit( self._last_successful_refresh = now_mono self._last_polling_interval_seconds = _polling_interval_seconds(revocation_list) - def _check_history(self) -> None: - if self._history_overflowed: - raise RevocationListFreshnessError("revocation history admission capacity exceeded") - def _grace_seconds(self) -> float: interval = self._last_polling_interval_seconds or MAX_POLLING_INTERVAL_SECONDS return interval * self._grace_multiplier - def _check_fetched_freshness(self, revocation_list: RevocationList, now: datetime) -> None: - deadline = _parse_iso8601(revocation_list.next_update).timestamp() + ( - _polling_interval_seconds(revocation_list) * self._grace_multiplier - ) - if now.timestamp() >= deadline: - raise RevocationListFreshnessError("fetched revocation list is past its grace window") - def _build_list_from_payload(payload: dict[str, Any], expected_issuer: str) -> RevocationList: """Validate the JWS payload schema and assemble a ``RevocationList``. @@ -618,10 +586,7 @@ def _build_list_from_payload(payload: dict[str, Any], expected_issuer: str) -> R f"spec floor ({MIN_POLLING_INTERVAL_SECONDS}s)" ) - try: - return RevocationList.from_dict(payload) - except (ValueError, TypeError) as exc: - raise RevocationListParseError("invalid revocation identifiers or details") from exc + return RevocationList.from_dict(payload) class CachingRevocationChecker(_CheckerState): @@ -692,7 +657,6 @@ def __init__( grace_multiplier: float = DEFAULT_GRACE_MULTIPLIER, clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), - history_admission: Callable[[RevocationList], bool] | None = None, ) -> None: # Reject the common footgun of passing time.time (wall-clock # seconds) as the `clock` kwarg — `_ensure_fresh` assumes @@ -721,7 +685,6 @@ def __init__( self._grace_multiplier = grace_multiplier self._clock = clock self._wall_clock = wall_clock - self._history_admission = history_admission self._init_state() @@ -735,7 +698,6 @@ def from_issuer_origin( grace_multiplier: float = DEFAULT_GRACE_MULTIPLIER, clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), - history_admission: Callable[[RevocationList], bool] | None = None, ) -> CachingRevocationChecker: """Build a checker from the issuer origin alone. @@ -759,7 +721,6 @@ def from_issuer_origin( grace_multiplier=grace_multiplier, clock=clock, wall_clock=wall_clock, - history_admission=history_admission, ) def prime(self) -> None: @@ -810,7 +771,6 @@ def __call__(self, keyid: str) -> bool: return self._current_list.is_revoked(keyid) def _ensure_fresh(self) -> None: - self._check_history() now_wall = self._wall_clock() now_mono = self._clock() @@ -854,8 +814,7 @@ def _ensure_fresh(self) -> None: ) grace_seconds = self._grace_seconds() - self._check_history() - if self._wall_clock().timestamp() >= next_update.timestamp() + grace_seconds: + if now_wall.timestamp() >= next_update.timestamp() + grace_seconds: raise RevocationListFreshnessError( f"revocation list {self._revocation_uri!r} past next_update " f"({self._current_list.next_update}) + grace ({grace_seconds:.0f}s); " @@ -890,11 +849,10 @@ def _refresh(self, *, conditional: bool, now_wall: datetime, now_mono: float) -> revocation_list = _post_jws_validation( payload, expected_issuer=self._issuer, - now_wall=self._wall_clock(), + now_wall=now_wall, current_list=self._current_list, ) self._commit(result=result, revocation_list=revocation_list, now_mono=now_mono) - self._check_fetched_freshness(revocation_list, self._wall_clock()) return True @@ -930,7 +888,6 @@ def __init__( grace_multiplier: float = DEFAULT_GRACE_MULTIPLIER, clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), - history_admission: Callable[[RevocationList], bool] | None = None, ) -> None: if clock is time.time: raise ValueError( @@ -951,7 +908,6 @@ def __init__( self._grace_multiplier = grace_multiplier self._clock = clock self._wall_clock = wall_clock - self._history_admission = history_admission self._init_state() # Eager lock construction: lazy-init was racy (two tasks both # seeing self._lock is None construct separate Locks and skip @@ -970,7 +926,6 @@ def from_issuer_origin( grace_multiplier: float = DEFAULT_GRACE_MULTIPLIER, clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), - history_admission: Callable[[RevocationList], bool] | None = None, ) -> AsyncCachingRevocationChecker: """Async variant of :meth:`CachingRevocationChecker.from_issuer_origin`.""" normalized = _normalize_issuer(origin) @@ -983,7 +938,6 @@ def from_issuer_origin( grace_multiplier=grace_multiplier, clock=clock, wall_clock=wall_clock, - history_admission=history_admission, ) async def aprime(self) -> None: @@ -1012,10 +966,8 @@ async def is_jti_revoked(self, jti: str) -> bool: return jti in self._current_list.revoked_jtis async def _ensure_fresh(self) -> None: - self._check_history() if self._current_list is None: async with self._lock: - self._check_history() if self._current_list is None: # Recompute clocks inside the lock: the awaiting task # may have been queued behind a slow refresh, so the @@ -1042,7 +994,6 @@ async def _ensure_fresh(self) -> None: ) try: async with self._lock: - self._check_history() # Re-check under the lock with fresh clock reads. now_mono_inside = self._clock() if self._last_refresh_attempt is None or ( @@ -1068,8 +1019,7 @@ async def _ensure_fresh(self) -> None: ) grace_seconds = self._grace_seconds() - self._check_history() - if self._wall_clock().timestamp() >= next_update.timestamp() + grace_seconds: + if now_wall.timestamp() >= next_update.timestamp() + grace_seconds: raise RevocationListFreshnessError( f"revocation list {self._revocation_uri!r} past next_update " f"({self._current_list.next_update}) + grace ({grace_seconds:.0f}s); " @@ -1117,11 +1067,10 @@ async def _refresh(self, *, conditional: bool, now_wall: datetime, now_mono: flo revocation_list = _post_jws_validation( payload, expected_issuer=self._issuer, - now_wall=self._wall_clock(), + now_wall=now_wall, current_list=self._current_list, ) self._commit(result=result, revocation_list=revocation_list, now_mono=now_mono) - self._check_fetched_freshness(revocation_list, self._wall_clock()) return True diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index 63478ee2e..bdf59621f 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -53,7 +53,6 @@ class SignedHeaders: signature_input: str signature: str content_digest: str | None = None - signature_agent: str | None = None def as_dict(self) -> dict[str, str]: headers: dict[str, str] = { @@ -62,8 +61,6 @@ def as_dict(self) -> dict[str, str]: } if self.content_digest is not None: headers["Content-Digest"] = self.content_digest - if self.signature_agent is not None: - headers["Signature-Agent"] = self.signature_agent return headers @@ -264,7 +261,6 @@ def sign_request( tag: str = DEFAULT_TAG, label: str = SIG_LABEL_DEFAULT, signing_profile_version: Literal["3.0", "3.1", "3.2"], - agent_origin: str | None = None, ) -> SignedHeaders: """Sign a request and return the headers to add to it. @@ -273,29 +269,6 @@ def sign_request( intentionally required: this low-level primitive has no negotiated AdCP version from which it could safely infer the wire encoding. """ - if agent_origin is not None: - from adcp.signing.wba import public_directory_key, sign_wba_request - - if key_id != public_directory_key(private_key)["kid"]: - raise ValueError("Web Bot Auth key_id must be the public key's RFC 7638 thumbprint") - result = sign_wba_request( - method=method, - url=url, - headers=headers, - body=body, - private_key=private_key, - agent_origin=agent_origin, - created=created, - expires_in_seconds=expires_in_seconds, - nonce=nonce, - label=label, - ) - return SignedHeaders( - signature_input=result["Signature-Input"], - signature=result["Signature"], - content_digest=result.get("Content-Digest"), - signature_agent=result["Signature-Agent"], - ) resolved_cover_content_digest = _resolve_content_digest_coverage( signing_profile_version=signing_profile_version, body=body, diff --git a/src/adcp/signing/signer_keys.py b/src/adcp/signing/signer_keys.py index d9eae7461..71ab76e2d 100644 --- a/src/adcp/signing/signer_keys.py +++ b/src/adcp/signing/signer_keys.py @@ -62,14 +62,6 @@ class SignerKeyResolver(Protocol): async def __call__(self, keyid: str) -> ResolvedSignerKey | None: ... -@runtime_checkable -class WbaSignerKeyResolver(Protocol): - """Resolve a WBA thumbprint only in the covered signer's origin directory.""" - - async def __call__(self, keyid: str, *, signer_origin: str) -> ResolvedSignerKey | None: - raise NotImplementedError("implement origin-scoped Web Bot Auth key resolution") - - def _jwks_keys(agent_url: str, jwks: Mapping[str, Any]) -> list[Mapping[str, Any]]: keys = jwks.get("keys") if not isinstance(keys, list): @@ -107,16 +99,8 @@ def __init__(self, jwks_by_agent: Mapping[str, Mapping[str, Any]]) -> None: index[kid] = ResolvedSignerKey(agent_url=agent_url, jwk=dict(key)) self._index = index - async def __call__( - self, keyid: str, *, signer_origin: str | None = None - ) -> ResolvedSignerKey | None: - key = self._index.get(keyid) - if signer_origin is not None and key is not None: - from adcp.signing.canonical import canonical_origin - - if canonical_origin(key.agent_url) != signer_origin: - return None - return key + async def __call__(self, keyid: str) -> ResolvedSignerKey | None: + return self._index.get(keyid) class JwksUriSignerKeys: @@ -174,12 +158,7 @@ async def _lookup( self._failures.pop(agent_url, None) return ResolvedSignerKey(agent_url=agent_url, jwk=jwk) if jwk is not None else None - async def __call__( - self, keyid: str, *, signer_origin: str | None = None - ) -> ResolvedSignerKey | None: - if signer_origin is not None: - # Legacy endpoints are not signed WBA directories. - return None + async def __call__(self, keyid: str) -> ResolvedSignerKey | None: results = await asyncio.gather(*(self._lookup(agent, keyid) for agent in self._resolvers)) matches = [result for result in results if isinstance(result, ResolvedSignerKey)] if len(matches) == 1: @@ -205,5 +184,4 @@ async def __call__( "ResolvedSignerKey", "SignerKeyResolver", "StaticSignerKeys", - "WbaSignerKeyResolver", ] diff --git a/src/adcp/signing/trust.py b/src/adcp/signing/trust.py deleted file mode 100644 index 4e17470c5..000000000 --- a/src/adcp/signing/trust.py +++ /dev/null @@ -1,1059 +0,0 @@ -"""Experimental trust.json evaluation at binding, grants, and open onboarding. - -No function in this module participates in transport key discovery. Successful -trust.json discovery supersedes legacy authorized_operators for that grantor. -""" - -from __future__ import annotations - -import asyncio -import copy -import json -import time -from collections import OrderedDict -from collections.abc import Awaitable, Callable, Mapping, Sequence -from dataclasses import dataclass, replace -from datetime import datetime -from functools import lru_cache -from importlib.resources import files -from typing import Any, Literal, Protocol, cast -from urllib.parse import urlsplit - -import httpx -from jsonschema import Draft7Validator, FormatChecker - -from adcp.signing._bounded_http import async_read_limited_bytes -from adcp.signing._strict_json import StrictJsonError, parse_strict_json -from adcp.signing.bindings import OriginBindings -from adcp.signing.canonical import canonical_origin -from adcp.signing.etld import registrable_domain -from adcp.signing.jwks import SSRFValidationError, validate_uri_static -from adcp.signing.verifier import VerifiedSigner - - -class TrustRecordError(ValueError): - """A missing, invalid or unavailable trust record provides no authority.""" - - -class TrustRecordNotFoundError(TrustRecordError): - """A definitive 404/410 provides no current operator membership.""" - - operator_domain: str | None = None - - -class TrustRecordAbsentError(TrustRecordNotFoundError): - """404/410 before any successful discovery permits configured legacy fallback.""" - - -class TrustRecordUnavailableError(TrustRecordError): - """A temporary outage cannot establish delisting or new spend authority.""" - - -class TrustRecordMatchError(TrustRecordError): - """A fetched record no longer lists the origin with its required role.""" - - -class _TrustRecordCapacityError(TrustRecordUnavailableError): - """Local backpressure proves nothing about authority and must not be cached.""" - - -class _TrustRecordBodyError(TrustRecordError): - """An observed HTTP 200 body was rejected before returning its bytes.""" - - -class _TrustRecordReadError(TrustRecordUnavailableError): - """An observed HTTP 200 publication could not finish streaming.""" - - -@dataclass(frozen=True) -class TrustResponse: - body: bytes - status: int = 200 - - -class TrustFetcher(Protocol): - async def __call__(self, url: str, *, allow_private: bool = False) -> TrustResponse: - raise NotImplementedError - - -def trust_record_url(origin_or_domain: str) -> str: - """Derive the record from the pinned ICANN+PRIVATE PSL, never a caller pointer.""" - domain = registrable_domain(origin_or_domain) - if domain is None: - raise TrustRecordError("identity has no registrable operator domain") - return f"https://{domain}/.well-known/trust.json" - - -async def fetch_trust_record(url: str, *, allow_private: bool = False) -> TrustResponse: - _validate_fetch_target(url) - from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport - - observed_200 = False - - async def fetch() -> TrustResponse: - nonlocal observed_200 - transport = await asyncio.to_thread( - build_async_ip_pinned_transport, url, allow_private=allow_private - ) - fetch_timeout = httpx.Timeout(10, connect=5) - async with httpx.AsyncClient( - transport=transport, - timeout=fetch_timeout, - follow_redirects=False, - trust_env=False, - ) as client: - async with client.stream( - "GET", url, headers={"Accept": "application/json", "Accept-Encoding": "identity"} - ) as response: - # Status determines removal/outage before any body guard. - # An oversized or encoded error page cannot replace policy. - if response.status_code != 200: - return TrustResponse(b"", response.status_code) - observed_200 = True - try: - body = await async_read_limited_bytes(response, limit=2 * 1024 * 1024) - except ValueError as exc: - raise _TrustRecordBodyError( - "trust record body is invalid or oversized" - ) from exc - except Exception as exc: - raise _TrustRecordReadError("trust record body could not be fetched") from exc - return TrustResponse( - body, - response.status_code, - ) - - try: - return await asyncio.wait_for(fetch(), timeout=10) - except (TimeoutError, asyncio.TimeoutError) as exc: - if observed_200: - raise _TrustRecordReadError("trust record body fetch timed out") from exc - raise - - -def _validate_fetch_target(url: str) -> None: - try: - parts = urlsplit(url) - except ValueError as exc: - raise TrustRecordError("invalid trust fetch target") from exc - if ( - parts.scheme != "https" - or parts.username is not None - or parts.password is not None - or any(ord(c) <= 0x20 for c in url) - ): - raise TrustRecordError("trust fetches require HTTPS without userinfo or whitespace") - try: - validate_uri_static(url) - except SSRFValidationError as exc: - raise TrustRecordError("invalid trust fetch target") from exc - - -@lru_cache(maxsize=1) -def identity_schema_bundle() -> dict[str, Any]: - return cast( - dict[str, Any], - json.loads(files("adcp.signing").joinpath("_identity_schemas/bundle.json").read_text()), - ) - - -def validate_identity_value(value: Mapping[str, Any], schema_path: str) -> None: - """Validate wire conditionals against the offline, pinned preview schemas.""" - key = schema_path.removesuffix(".json").replace("/", "_").replace("-", "_") - bundle = identity_schema_bundle() - schema = {"$ref": f"#/definitions/{key}", "definitions": bundle["definitions"]} - errors = list(Draft7Validator(schema, format_checker=FormatChecker()).iter_errors(value)) - if errors: - raise TrustRecordError("value does not conform to the experimental identity schema") - - -def validate_trust_record(document: Mapping[str, Any], *, operator_domain: str) -> dict[str, Any]: - """Author/validate a record with one agent per canonical origin and no keys.""" - return _validate_trust_record(document, operator_domain=operator_domain) - - -def _validate_trust_record( - document: Mapping[str, Any], *, operator_domain: str, reader: bool = False -) -> dict[str, Any]: - schema_document = _trust_reader_schema_document(document) if reader else document - validate_identity_value(schema_document, "trust/v1/trust.json") - if "authoritative_location" in document: - return dict(document) - operator = registrable_domain(operator_domain) - if operator is None: - raise TrustRecordError("record owner has no registrable domain") - seen: set[str] = set() - for agent in document.get("agents", []): - raw = agent["url"] - parts = urlsplit(raw) - if parts.username is not None or parts.password is not None: - raise TrustRecordError("agent URLs must not contain userinfo") - origin = canonical_origin(raw) - if not origin.startswith("https://") or registrable_domain(origin) != operator: - raise TrustRecordError("agent origin does not belong to the record owner") - if origin in seen: - raise TrustRecordError("a trust record must list one agent per origin") - seen.add(origin) - for grant in document.get("grants", []): - if registrable_domain(grant["grantee"]) != grant["grantee"]: - raise TrustRecordError("grant grantee must be an exact registrable domain") - return dict(document) - - -def _trust_reader_schema_document(document: Mapping[str, Any]) -> Mapping[str, Any]: - """Keep unknown advisory requirements without relaxing authority-bearing fields.""" - schema_document = copy.deepcopy(dict(document)) - known_kinds = identity_schema_bundle()["definitions"][ - "enums_agent_onboarding_requirement_kind" - ]["enum"] - - def preserve_posture(profiles: Any) -> None: - if not isinstance(profiles, dict): - return - adcp = profiles.get("adcp") - posture = adcp.get("onboarding") if isinstance(adcp, dict) else None - if not isinstance(posture, dict): - return - requirements = posture.get("requirements") - if not isinstance(requirements, list): - return - # Requirements affect onboarding advice only. Future kinds cannot be - # machine-satisfied; readers retain them for message/url fallback. - posture["requirements"] = [ - requirement - for requirement in requirements - if not ( - isinstance(requirement, dict) - and isinstance(requirement.get("kind"), str) - and requirement["kind"] not in known_kinds - ) - ] - - preserve_posture(schema_document.get("profiles")) - agents = schema_document.get("agents") - if isinstance(agents, list): - for agent in agents: - if isinstance(agent, dict): - preserve_posture(agent.get("profiles")) - return schema_document - - -@dataclass(frozen=True) -class EvaluatedTrustRecord: - operator_domain: str - source_url: str - document: Mapping[str, Any] - expires_at: float - stale_on_outage: bool = False - revision: int = 0 - - def agent(self, origin: str, *, role: str) -> Mapping[str, Any]: - origin = canonical_origin(origin) - matches = [ - agent - for agent in self.document.get("agents", []) - if canonical_origin(agent["url"]) == origin - ] - if len(matches) != 1 or role not in matches[0]["roles"]: - raise TrustRecordMatchError( - "record must list exactly one matching agent with the required role" - ) - return dict(matches[0]) - - -@dataclass(frozen=True) -class _TrustRecordFailure: - error_type: type[TrustRecordError] - message: str - retry_after: float - - -class TrustRecordEvaluator: - """Fetch one authoritative-location hop and cache no longer than revocation polling. - - A non-200 fetch is unavailable except for definitive 404/410 absence or - removal. Invalid 200 documents reject policy; outages preserve the last - policy for explicit bounded grace. Custom fetchers enforce SSRF policy. - """ - - def __init__( - self, - *, - fetcher: TrustFetcher = fetch_trust_record, - allow_private: bool = False, - polling_interval_seconds: float = 60, - max_records: int = 256, - max_history_records: int = 256, - max_fetches_per_host: int = 4, - clock: Callable[[], float] = time.time, - ) -> None: - if ( - not 0 < polling_interval_seconds <= 1800 - or type(max_records) is not int - or max_records <= 0 - or type(max_history_records) is not int - or max_history_records <= 0 - or type(max_fetches_per_host) is not int - or max_fetches_per_host <= 0 - ): - raise ValueError("invalid trust cache bounds") - self._fetcher, self._allow_private, self._interval = ( - fetcher, - allow_private, - polling_interval_seconds, - ) - self._max_records, self._clock = max_records, clock - self._max_history_records = max_history_records - # One authoritative record per eTLD+1. Pending owners reserve their - # slot before any response can establish irreversible publication. - self._history_owners: set[str] = set() - # A previously used non-publisher also keeps its admission, so a - # hosted-tenant flood cannot disable its configured legacy policy. - # This is not reusable absence evidence during an outage. - self._known_absent_owners: set[str] = set() - self._cache: OrderedDict[str, EvaluatedTrustRecord] = OrderedDict() - self._failures: OrderedDict[str, _TrustRecordFailure] = OrderedDict() - self._pending: dict[str, asyncio.Task[EvaluatedTrustRecord]] = {} - self._max_fetches_per_host = max_fetches_per_host - self._responses: OrderedDict[str, tuple[float, TrustResponse]] = OrderedDict() - self._response_pending: dict[str, asyncio.Task[tuple[float, TrustResponse]]] = {} - self._hosts_inflight: dict[tuple[str, str], int] = {} - self._host_totals: dict[str, int] = {} - self._host_failures: OrderedDict[tuple[str, str], float] = OrderedDict() - self._seen_200: set[str] = set() - # Authoritative history is never evicted. New operators are refused - # before fetching when the permanent admission budget is full. - self._last_policy: OrderedDict[str, EvaluatedTrustRecord | _TrustRecordFailure] = ( - OrderedDict() - ) - self._policy_revision = 0 - self._lock = asyncio.Lock() - - @property - def polling_interval_seconds(self) -> float: - """The freshness cadence and basis for the four-interval outage grace.""" - return self._interval - - def current_time(self) -> float: - """Read the evaluator's clock for a final authority deadline check.""" - return self._clock() - - def _remember_policy( - self, domain: str, policy: EvaluatedTrustRecord | _TrustRecordFailure - ) -> None: - self._last_policy[domain] = policy - self._last_policy.move_to_end(domain) - - async def cached_record( - self, origin_or_domain: str, *, role: str | None = None - ) -> EvaluatedTrustRecord: - """Inspect latest policy during an outage, never fresh spend authority. - - A positive snapshot is usable only through freshness plus four polling - intervals. Negative reads remain negative even beyond that window. - Unknown history fails closed. No fetch is performed. - """ - domain = urlsplit(trust_record_url(origin_or_domain)).hostname or "" - async with self._lock: - policy = self._last_policy.get(domain) - if policy is None: - raise TrustRecordUnavailableError("no prior trust record is available") - if isinstance(policy, _TrustRecordFailure): - error = policy.error_type(policy.message) - if isinstance(error, TrustRecordNotFoundError): - error.operator_domain = domain - raise error - if role is not None: - # Consult the latest document before its grace deadline: an - # origin delisted by another request cannot regain authority. - policy.agent(origin_or_domain, role=role) - if self._clock() >= policy.expires_at + 4 * self._interval: - raise TrustRecordUnavailableError("trust record outage grace has expired") - return EvaluatedTrustRecord( - policy.operator_domain, - policy.source_url, - copy.deepcopy(policy.document), - policy.expires_at, - stale_on_outage=True, - revision=policy.revision, - ) - - async def snapshots_current(self, records: Sequence[EvaluatedTrustRecord]) -> bool: - """Compare captured authority with every latest owner policy atomically. - - A newer read, removal, invalid document, or evicted history invalidates - the captured decision. Re-evaluate before approving an older snapshot. - """ - async with self._lock: - return all( - isinstance( - policy := self._last_policy.get(record.operator_domain), EvaluatedTrustRecord - ) - and policy.revision == record.revision - for record in records - ) - - async def validate_snapshots( - self, - records: Sequence[EvaluatedTrustRecord], - *, - origin_or_domain: str | None = None, - role: str | None = None, - legacy_unpublished_domain: str | None = None, - ) -> None: - """Recheck latest policy, revision and deadline before assigning authority. - - ``origin_or_domain``/``role`` match the first record's latest agent entry, - even when another origin refreshed that owner's policy. This final - guard never upgrades an older captured snapshot to a newer approval. - """ - async with self._lock: - if ( - legacy_unpublished_domain is not None - and registrable_domain(legacy_unpublished_domain) in self._seen_200 - ): - raise TrustRecordUnavailableError("trust publication superseded legacy authority") - for index, record in enumerate(records): - policy = self._last_policy.get(record.operator_domain) - if policy is None: - raise TrustRecordUnavailableError("trust policy history was evicted") - if isinstance(policy, _TrustRecordFailure): - error = policy.error_type(policy.message) - if isinstance(error, TrustRecordNotFoundError): - error.operator_domain = record.operator_domain - raise error - if index == 0 and origin_or_domain is not None and role is not None: - policy.agent(origin_or_domain, role=role) - if policy.revision != record.revision: - raise TrustRecordUnavailableError("trust policy changed during evaluation") - deadline = record.expires_at + (4 * self._interval if record.stale_on_outage else 0) - if self._clock() >= deadline: - raise TrustRecordUnavailableError("trust policy expired during evaluation") - - async def evaluate( - self, - origin_or_domain: str, - *, - role: str | None = None, - allow_stale_on_outage: bool = False, - ) -> EvaluatedTrustRecord: - """Require fresh policy unless an existing non-spend caller opts into grace.""" - try: - return await self._evaluate_fresh(origin_or_domain, role=role) - except TrustRecordUnavailableError as outage: - try: - cached = await self.cached_record(origin_or_domain, role=role) - except TrustRecordUnavailableError as history_error: - raise history_error if allow_stale_on_outage else outage - # Retained delisting/removal/content errors are checked above even - # for spend. A cap must not disguise a known denial as an outage. - if not allow_stale_on_outage: - raise outage - return cached - - async def _evaluate_fresh( - self, origin_or_domain: str, *, role: str | None = None - ) -> EvaluatedTrustRecord: - url = trust_record_url(origin_or_domain) - domain = urlsplit(url).hostname or "" - async with self._lock: - now = self._clock() - record = self._cache.get(domain) - if record is None or record.expires_at <= now: - if ( - domain not in self._history_owners - and len(self._history_owners) >= self._max_history_records - ): - raise _TrustRecordCapacityError("trust history admission capacity exceeded") - failure = self._failures.get(domain) - if failure is not None and failure.retry_after > now: - self._failures.move_to_end(domain) - raise failure.error_type(failure.message) - task = self._pending.get(domain) - if task is None: - if len(self._pending) >= self._max_records: - raise _TrustRecordCapacityError("trust discovery capacity exceeded") - self._history_owners.add(domain) - task = asyncio.create_task(self._refresh(domain, url, now)) - # One disconnected caller cannot cancel the shared fetch or - # leave a background task's exception unobserved. - task.add_done_callback( - lambda done: done.exception() if not done.cancelled() else None - ) - self._pending[domain] = task - else: - self._cache.move_to_end(domain) - task = None - if task is not None: - record = await asyncio.shield(task) - assert record is not None - if record.expires_at <= self._clock(): - raise TrustRecordUnavailableError("trust record expired while being evaluated") - if role is not None: - record.agent(origin_or_domain, role=role) - # Callers cannot mutate cached authority through a returned mapping. - import copy - - return EvaluatedTrustRecord( - record.operator_domain, - record.source_url, - copy.deepcopy(record.document), - record.expires_at, - revision=record.revision, - ) - - async def _refresh(self, domain: str, url: str, now: float) -> EvaluatedTrustRecord: - try: - record = await self._fetch(domain, url, now) - async with self._lock: - self._policy_revision += 1 - record = replace(record, revision=self._policy_revision) - self._cache[domain] = record - self._remember_policy(domain, record) - self._cache.move_to_end(domain) - self._failures.pop(domain, None) - while len(self._cache) > self._max_records: - self._cache.popitem(last=False) - return record - except _TrustRecordCapacityError: - # A busy local fetch pool is not evidence of a counterparty outage. - # Retry immediately once a slot becomes available. - raise - except Exception as exc: - error = ( - exc - if isinstance(exc, TrustRecordError) - else TrustRecordUnavailableError("trust record fetch failed") - ) - async with self._lock: - failure = _TrustRecordFailure(type(error), str(error), self._clock() + 60) - self._failures[domain] = failure - if isinstance(error, TrustRecordAbsentError): - self._known_absent_owners.add(domain) - if not isinstance(error, (TrustRecordUnavailableError, TrustRecordAbsentError)): - # Removal and malformed policy supersede older authority; - # subsequent network/rate/capacity failures never undo it. - self._cache.pop(domain, None) - self._remember_policy(domain, failure) - self._failures.move_to_end(domain) - while len(self._failures) > self._max_records: - self._failures.popitem(last=False) - if error is exc: - raise - raise error from exc - finally: - async with self._lock: - self._pending.pop(domain, None) - if ( - domain not in self._seen_200 - and domain not in self._last_policy - and domain not in self._known_absent_owners - ): - self._history_owners.discard(domain) - - async def _fetch(self, domain: str, url: str, now: float) -> EvaluatedTrustRecord: - source = url - expires_at = now + self._interval - for hop in range(2): - _validate_fetch_target(source) - try: - response_expires, response = await self._fetch_response(source, owner_domain=domain) - expires_at = min(expires_at, response_expires) - except _TrustRecordCapacityError: - raise - except (_TrustRecordBodyError, _TrustRecordReadError): - # Publication is established by status even if its body fails. - # A later 404 cannot reopen authorized_operators fallback. - self._seen_200.add(domain) - raise - except TrustRecordError: - raise - except Exception as exc: - raise TrustRecordUnavailableError("trust record fetch failed") from exc - if response.status in {404, 410} and hop == 0 and domain not in self._seen_200: - raise TrustRecordAbsentError("no trust record is published") - if response.status in {404, 410}: - raise TrustRecordNotFoundError("operator trust record was removed") - if response.status != 200: - raise TrustRecordUnavailableError("trust record is temporarily unavailable") - self._seen_200.add(domain) - if len(response.body) > 2 * 1024 * 1024: - raise TrustRecordError("trust record is unavailable or oversized") - try: - document = parse_strict_json(response.body) - except StrictJsonError as exc: - raise TrustRecordError("trust record is not strict JSON") from exc - if not isinstance(document, dict): - raise TrustRecordError("trust record must be an object") - document = _validate_trust_record(document, operator_domain=domain, reader=True) - pointer = document.get("authoritative_location") - if pointer is None: - return EvaluatedTrustRecord(domain, source, document, expires_at) - if hop == 1: - raise TrustRecordError("a second authoritative-location hop is forbidden") - source = pointer - raise TrustRecordError("trust record resolution failed") - - async def _fetch_response(self, url: str, *, owner_domain: str) -> tuple[float, TrustResponse]: - """Share transport bytes while preserving each original owner's validation.""" - host = urlsplit(url).hostname or "" - owner_host = (owner_domain, host) - async with self._lock: - now = self._clock() - cached = self._responses.get(url) - if cached is not None and cached[0] > now: - self._responses.move_to_end(url) - return cached - if self._host_failures.get(owner_host, 0) > now: - self._host_failures.move_to_end(owner_host) - raise TrustRecordUnavailableError("trust fetch host is cooling down") - task = self._response_pending.get(url) - if task is None: - if ( - len(self._response_pending) >= self._max_records - or self._host_totals.get(host, 0) >= self._max_fetches_per_host - ): - raise _TrustRecordCapacityError("trust fetch host capacity exceeded") - task = asyncio.create_task(self._refresh_response(url, owner_host, now)) - task.add_done_callback( - lambda done: done.exception() if not done.cancelled() else None - ) - self._response_pending[url] = task - self._hosts_inflight[owner_host] = self._hosts_inflight.get(owner_host, 0) + 1 - # Bound actual outbound pressure across owners, but do not - # turn this temporary resource limit into cached denial. - self._host_totals[host] = self._host_totals.get(host, 0) + 1 - return await asyncio.shield(task) - - async def _refresh_response( - self, url: str, owner_host: tuple[str, str], now: float - ) -> tuple[float, TrustResponse]: - try: - response = await self._fetcher(url, allow_private=self._allow_private) - if response.status != 200: - response = TrustResponse(b"", response.status) - elif len(response.body) > 2 * 1024 * 1024: - raise _TrustRecordBodyError("trust record is oversized") - async with self._lock: - ttl = self._interval if response.status == 200 else 60 - cached = (now + ttl, response) - self._responses[url] = cached - self._responses.move_to_end(url) - while len(self._responses) > self._max_records: - self._responses.popitem(last=False) - if response.status >= 500 or response.status == 429: - self._cool_down_host(owner_host) - return cached - except _TrustRecordCapacityError: - raise - except _TrustRecordReadError: - async with self._lock: - self._cool_down_host(owner_host) - raise - except Exception as exc: - # Validation/content errors (including the streaming size and - # encoding guards) describe one URL, not a host outage. - if not isinstance(exc, ValueError): - async with self._lock: - self._cool_down_host(owner_host) - raise TrustRecordUnavailableError("trust record fetch failed") from exc - if isinstance(exc, TrustRecordError): - raise - raise TrustRecordError("trust record content is invalid") from exc - finally: - async with self._lock: - self._response_pending.pop(url, None) - remaining = self._hosts_inflight[owner_host] - 1 - if remaining: - self._hosts_inflight[owner_host] = remaining - else: - self._hosts_inflight.pop(owner_host, None) - host = owner_host[1] - host_remaining = self._host_totals[host] - 1 - if host_remaining: - self._host_totals[host] = host_remaining - else: - self._host_totals.pop(host, None) - - def _cool_down_host(self, owner_host: tuple[str, str]) -> None: - """A pointer selected by one owner cannot suppress another owner's fetches.""" - self._host_failures[owner_host] = self._clock() + 60 - self._host_failures.move_to_end(owner_host) - while len(self._host_failures) > self._max_records: - self._host_failures.popitem(last=False) - - async def bind( - self, - signer: VerifiedSigner, - *, - principal_id: str, - bindings: OriginBindings, - existing_rfc9421_principal: bool = False, - migration_approved: bool = False, - role: str = "adcp:buying", - ) -> EvaluatedTrustRecord: - """Explicitly bind a verified origin after evaluating its operator record.""" - if signer.profile != "web-bot-auth" or signer.agent_url is None: - raise TrustRecordError("binding requires a verified Web Bot Auth identity") - record = await self.evaluate(signer.agent_url, role=role) - bindings.bind( - signer.agent_url, - principal_id, - existing_rfc9421_principal=existing_rfc9421_principal, - migration_approved=migration_approved, - ) - return record - - -@dataclass(frozen=True) -class GrantAuthorization: - authorized: bool - reason: str | None = None - legacy_fallback_allowed: bool = False - expires_at: float | None = None - error_code: Literal["PERMISSION_DENIED", "SERVICE_UNAVAILABLE"] = "PERMISSION_DENIED" - include_grant_absent_details: bool = True - outage_expires_at: float | None = None - stale_on_outage: bool = False - - def require(self) -> None: - """Only call after signer establishment/registry authorization, avoiding an oracle.""" - if not self.authorized: - from adcp.decisioning.types import AdcpError - - if self.error_code == "SERVICE_UNAVAILABLE": - raise AdcpError( - "SERVICE_UNAVAILABLE", - message="Trust record is temporarily unavailable", - recovery="transient", - ) - raise AdcpError( - "PERMISSION_DENIED", - message="Agent has no authority for this brand", - details=( - {"scope": "agent", "reason": "grant_absent"} - if self.include_grant_absent_details - else None - ), - recovery="correctable", - ) - - -@dataclass(frozen=True) -class GrantRequest: - """Scoped claim passed to explicit seller and legacy authorization hooks.""" - - agent_origin: str - brand_domain: str - account_operator: str - scope: str - brand_id: str | None - countries: tuple[str, ...] - - -def _scope_covers(scopes: Sequence[str], scope: str) -> bool: - return scope in scopes or "*" in scopes or (scope.startswith("adcp:") and "adcp:all" in scopes) - - -def _trust_grant_failure(error: TrustRecordError, reason: str) -> GrantAuthorization: - """Keep an outage distinct from an uncovered grant after identity establishment.""" - return GrantAuthorization( - False, - reason, - error_code=( - "SERVICE_UNAVAILABLE" - if isinstance(error, TrustRecordUnavailableError) - else "PERMISSION_DENIED" - ), - include_grant_absent_details=False, - ) - - -def _expired_grant_evaluation(reason: str) -> GrantAuthorization: - return GrantAuthorization( - False, - reason, - error_code="SERVICE_UNAVAILABLE", - include_grant_absent_details=False, - ) - - -def _grant_matches( - grant: Mapping[str, Any], - *, - grantee: str, - origin: str, - scope: str, - brand_id: str | None, - countries: frozenset[str], - now: float, -) -> bool: - if grant["grantee"] != grantee or not _scope_covers(grant["scopes"], scope): - return False - agents = grant.get("agents") - if agents is not None and not any(canonical_origin(agent["url"]) == origin for agent in agents): - return False - if "countries" in grant and (not countries or not countries <= set(grant["countries"])): - return False - brands = grant.get("profiles", {}).get("adcp", {}).get("brands", ["*"]) - if "*" not in brands and (brand_id is None or brand_id not in brands): - return False - for field, before in (("valid_from", True), ("valid_until", False)): - if field in grant: - instant = datetime.fromisoformat(grant[field].replace("Z", "+00:00")).timestamp() - if (before and now < instant) or (not before and now >= instant): - return False - return True - - -class TrustGrantEvaluator: - """Evaluate direct or one-intermediary demand grants against the brand's record. - - Checking every requested dimension at every link implements intersection - without allowing a grantee to expand authority. Seller discretion is a - separate explicit hook, never a brand.json fallback after HTTP 200. - """ - - def __init__( - self, - records: TrustRecordEvaluator, - *, - clock: Callable[[], float] | None = None, - seller_authorization: Callable[[GrantRequest], Awaitable[bool]] | None = None, - legacy_authorization: Callable[[GrantRequest], Awaitable[bool]] | None = None, - ) -> None: - self.records = records - self._clock = clock if clock is not None else records._clock - self._seller_authorization = seller_authorization - self._legacy_authorization = legacy_authorization - - def current_time(self) -> float: - """Read the evaluation clock before using a retained claim deadline.""" - return self._clock() - - def _record_deadline(self, record: EvaluatedTrustRecord) -> float: - return record.expires_at + ( - 4 * self.records.polling_interval_seconds if record.stale_on_outage else 0 - ) - - async def _authorization( - self, - authorized: bool, - records: Sequence[EvaluatedTrustRecord], - *, - grant_expiry: float = float("inf"), - legacy_fallback_allowed: bool = False, - agent_origin: str | None = None, - legacy_unpublished_domain: str | None = None, - ) -> GrantAuthorization: - if authorized: - try: - await self.records.validate_snapshots( - records, - origin_or_domain=agent_origin, - role="adcp:buying" if agent_origin is not None else None, - legacy_unpublished_domain=legacy_unpublished_domain, - ) - except TrustRecordNotFoundError as exc: - if any(record.operator_domain == exc.operator_domain for record in records[1:]): - return GrantAuthorization(False, "grant_absent") - return _trust_grant_failure(exc, "signer_trust_record_unavailable") - except TrustRecordError as exc: - return _trust_grant_failure(exc, "trust_record_changed_during_evaluation") - if any(self._record_deadline(record) <= self._clock() for record in records): - return _expired_grant_evaluation("trust_record_unavailable") - if grant_expiry <= self._clock(): - return GrantAuthorization(False, "grant_absent") - return GrantAuthorization( - authorized, - legacy_fallback_allowed=legacy_fallback_allowed, - expires_at=( - min([record.expires_at for record in records] + [grant_expiry]) if records else None - ), - outage_expires_at=( - min( - [ - record.expires_at + 4 * self.records.polling_interval_seconds - for record in records - ] - + [grant_expiry] - ) - if authorized and records - else None - ), - stale_on_outage=any(record.stale_on_outage for record in records), - ) - - async def check( - self, - *, - agent_origin: str, - brand_domain: str, - account_operator: str, - scope: str = "adcp:media_buying", - brand_id: str | None = None, - countries: Sequence[str] = (), - signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - allow_stale_on_outage: bool = False, - ) -> GrantAuthorization: - origin = canonical_origin(agent_origin) - signer_operator = registrable_domain(origin) - brand_operator = registrable_domain(brand_domain) - operator = registrable_domain(account_operator) - if signer_operator is None or brand_operator is None or operator is None: - return GrantAuthorization(False, "grant_absent", include_grant_absent_details=False) - request = GrantRequest( - origin, brand_domain, account_operator, scope, brand_id, tuple(countries) - ) - if signing_profile not in {"web-bot-auth", "adcp/request-signing/v1"}: - raise ValueError("unsupported authenticated signing profile") - signer_record: EvaluatedTrustRecord | None = None - if signing_profile == "web-bot-auth": - try: - # WBA authority always requires a current operator role entry. - signer_record = await self.records.evaluate( - origin, role="adcp:buying", allow_stale_on_outage=allow_stale_on_outage - ) - except TrustRecordError as exc: - return _trust_grant_failure(exc, "signer_trust_record_unavailable") - if signer_operator == brand_operator == operator: - return await self._authorization(True, [signer_record], agent_origin=origin) - try: - brand_record = await self.records.evaluate( - brand_domain, allow_stale_on_outage=allow_stale_on_outage - ) - except TrustRecordAbsentError: - if self._legacy_authorization is not None: - authorized = bool(await self._legacy_authorization(request)) - if ( - signer_record is not None - and self._record_deadline(signer_record) <= self._clock() - ): - return _expired_grant_evaluation("signer_trust_record_unavailable") - if signer_record is not None: - return await self._authorization( - authorized, - [signer_record], - legacy_fallback_allowed=True, - agent_origin=origin, - legacy_unpublished_domain=brand_domain, - ) - return await self._authorization( - authorized, - [], - legacy_fallback_allowed=True, - legacy_unpublished_domain=brand_domain, - ) - return GrantAuthorization(False, "trust_record_absent", legacy_fallback_allowed=True) - except TrustRecordNotFoundError: - return GrantAuthorization(False, "grant_absent") - except TrustRecordError as exc: - return _trust_grant_failure(exc, "trust_record_unavailable") - if signer_record is None: - # Legacy credentials retain their established onboarding path when - # the brand has no trust record. Once the brand serves HTTP 200, - # grants have one source and require the new operator role entry. - try: - signer_record = await self.records.evaluate( - origin, role="adcp:buying", allow_stale_on_outage=allow_stale_on_outage - ) - except TrustRecordError as exc: - return _trust_grant_failure(exc, "signer_trust_record_unavailable") - if signer_operator == brand_operator == operator: - return await self._authorization(True, [signer_record], agent_origin=origin) - args: dict[str, Any] = { - "origin": origin, - "scope": scope, - "brand_id": brand_id, - "countries": frozenset(countries), - "now": self._clock(), - } - direct = operator in {signer_operator, brand_operator} - grantee = signer_operator if direct else operator - first = [ - g - for g in brand_record.document.get("grants", []) - if _grant_matches(g, grantee=grantee, **args) - ] - links: list[list[Mapping[str, Any]]] = [] - used_records = [signer_record, brand_record] - if direct: - links = [first] - elif first: - try: - intermediary = await self.records.evaluate( - operator, allow_stale_on_outage=allow_stale_on_outage - ) - except TrustRecordNotFoundError: - return GrantAuthorization(False, "grant_absent") - except TrustRecordError as exc: - return _trust_grant_failure(exc, "trust_record_unavailable") - used_records.append(intermediary) - # Fetching the intermediary may outlive an earlier grant. Evaluate - # both links at the decision time, not at request receipt. - args["now"] = self._clock() - first = [g for g in first if _grant_matches(g, grantee=operator, **args)] - second = [ - g - for g in intermediary.document.get("grants", []) - if _grant_matches(g, grantee=signer_operator, **args) - ] - links = [first, second] - if any(self._record_deadline(record) <= self._clock() for record in used_records): - return _expired_grant_evaluation("trust_record_unavailable") - if not links or any(not link for link in links): - exhaustive = brand_record.document.get("exclusive_grants", False) and any( - _scope_covers(grant["scopes"], scope) - for grant in brand_record.document.get("grants", []) - ) - if not exhaustive and self._seller_authorization is not None: - authorized = bool(await self._seller_authorization(request)) - expiry = min(self._record_deadline(record) for record in used_records) - # Seller discretion cannot outlive the required operator role - # evaluation while its application-owned policy lookup awaits. - if expiry <= self._clock(): - return _expired_grant_evaluation("trust_record_unavailable") - return await self._authorization(authorized, used_records, agent_origin=origin) - return GrantAuthorization(False, "grant_absent") - # Every surviving grant covers the same requested dimensions. The - # best path's expiry is the minimum of each link's best expiry. Avoid - # materializing the cartesian product of untrusted grant arrays. - grant_expiry = min( - [ - max( - ( - datetime.fromisoformat(g["valid_until"].replace("Z", "+00:00")).timestamp() - if "valid_until" in g - else float("inf") - ) - for g in link - ) - for link in links - ] - ) - if grant_expiry <= self._clock(): - return GrantAuthorization(False, "grant_absent") - if any(self._record_deadline(record) <= self._clock() for record in used_records): - return _expired_grant_evaluation("trust_record_unavailable") - return await self._authorization( - True, used_records, grant_expiry=grant_expiry, agent_origin=origin - ) - - -__all__ = [ - "GrantRequest", - "EvaluatedTrustRecord", - "GrantAuthorization", - "TrustFetcher", - "TrustGrantEvaluator", - "TrustRecordAbsentError", - "TrustRecordError", - "TrustRecordMatchError", - "TrustRecordNotFoundError", - "TrustRecordUnavailableError", - "TrustRecordEvaluator", - "TrustResponse", - "trust_record_url", - "validate_trust_record", - "validate_identity_value", -] diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 135b247e1..0e09896a5 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -122,8 +122,6 @@ class VerifiedSigner: parsed_body: Any = field(default=None, compare=False, repr=False) body_authenticated: bool = field(default=False, compare=False) operator_brand_json: dict[str, Any] | None = field(default=None, compare=False, repr=False) - profile: str = DEFAULT_TAG - principal_id: str | None = None class RequestBodyMalformedError(SignatureVerificationError): @@ -266,12 +264,6 @@ def verify_request_signature( supply a meaningful raw list at all. """ body = _coerce_body(body) - from adcp.signing.wba import WbaVerificationError, uses_web_bot_auth - - if uses_web_bot_auth(headers): - raise WbaVerificationError( - "Web Bot Auth requires verify_wba_request; legacy fallback is forbidden" - ) sig_input_raw = _lookup(headers, "signature-input") sig_raw = _lookup(headers, "signature") diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py deleted file mode 100644 index a8e7abd53..000000000 --- a/src/adcp/signing/wba.py +++ /dev/null @@ -1,969 +0,0 @@ -"""Experimental AdCP 3.3 Web Bot Auth transport profile. - -Key discovery establishes an origin identity, never commercial authority. -Governance JWS verification continues to use the RFC 9421 discovery chain. -""" - -from __future__ import annotations - -import asyncio -import json -import secrets -import time -from collections import OrderedDict -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any, Protocol -from urllib.parse import urlsplit - -import httpx -from cryptography.hazmat.primitives.asymmetric import ec, ed25519 -from http_sfv.dictionary import Dictionary -from http_sfv.item import InnerList, Item - -from adcp.signing._bounded_http import async_read_limited_bytes -from adcp.signing._header_precheck import strict_header_precheck -from adcp.signing._strict_json import StrictJsonError, parse_strict_json -from adcp.signing._structured_fields import dictionary_has_parameter, reject_dictionary_duplicates -from adcp.signing.canonical import ( - _lookup, - canonical_origin, - canonicalize_authority, - canonicalize_target_uri, -) -from adcp.signing.crypto import ( - PrivateKey, - b64url_encode, - public_key_from_jwk, - sign_signature_base, - verify_signature, -) -from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches -from adcp.signing.jwks import SSRFValidationError, validate_uri_static -from adcp.signing.publisher_pins import jwk_thumbprint -from adcp.signing.replay import ReplayStore -from adcp.signing.signer_keys import ResolvedSignerKey, SignerKeyResolver, WbaSignerKeyResolver -from adcp.signing.verifier import VerifiedSigner, _claim_replay_nonce - -WBA_TAG = "web-bot-auth" -DIRECTORY_TAG = "http-message-signatures-directory" -DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" -DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json" - - -class WbaVerificationError(ValueError): - """A WBA failure, expressed as HTTP status and response headers.""" - - def __init__(self, reason: str, *, status: int = 403, label: str = "sig1") -> None: - super().__init__(reason) - self.reason = reason - self.status = status - self.headers: dict[str, str] = {} - if status == 403: - # RFC 9421 §5.1: parameters indicate requirements, not values to sign. - member = InnerList( - [ - Item(c) - for c in ( - "@method", - "@target-uri", - "@authority", - "content-type", - "content-digest", - ) - ] - ) - agent = Item("signature-agent") - agent.params["key"] = label - member.append(agent) - member.params.update( - { - "created": True, - "expires": True, - "keyid": True, - "nonce": b64url_encode(secrets.token_bytes(32)), - "tag": WBA_TAG, - } - ) - self.headers["Accept-Signature"] = str(Dictionary({label: member})) - - -class WbaAuthorizationError(WbaVerificationError): - """A key-policy rejection uses AdCP authentication semantics without a re-sign challenge. - - A revoked key establishes no identity. Principal and grant authorization - after establishment uses the normal AdCP transport error binding. - """ - - def __init__(self, reason: str) -> None: - super().__init__(reason) - self.headers = {} - - -class WbaUnavailableError(WbaVerificationError): - """Identity cannot be checked during an outage; retry without serving the request.""" - - def __init__(self, reason: str) -> None: - super().__init__(reason, status=503) - - -def _dictionary(value: str | None) -> Dictionary: - if value is None or len(value) > 65536: - raise WbaVerificationError("missing or oversized signature field", status=400) - try: - reject_dictionary_duplicates(value) - result = Dictionary() - result.parse(value.encode("ascii")) - if not result: - raise ValueError("empty dictionary") - return result - except (ValueError, UnicodeError) as exc: - raise WbaVerificationError(f"malformed signature field: {exc}", status=400) from exc - - -def uses_web_bot_auth(headers: Mapping[str, str]) -> bool: - """Select WBA from either marker; never fall back after a WBA failure.""" - if _lookup(headers, "signature-agent") is not None: - return True - raw = _lookup(headers, "signature-input") - if raw is None: - return False - # Observe every explicit tag, including one an ambiguous duplicate would - # overwrite. Text inside nonce/component strings cannot select a profile. - return dictionary_has_parameter(raw, "tag", WBA_TAG) - - -def _https_origin(url: str) -> str: - if any(ord(c) < 0x21 for c in url): - raise ValueError("origin URL contains whitespace or controls") - parts = urlsplit(url) - origin = canonical_origin(url) - if ( - parts.username is not None - or parts.password is not None - or not origin.startswith("https://") - ): - raise ValueError("Web Bot Auth origins require HTTPS without userinfo") - try: - validate_uri_static(origin) - except SSRFValidationError as exc: - raise ValueError("invalid Web Bot Auth origin") from exc - return origin - - -def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: - """Return an RFC 7638 thumbprint for supported public key material.""" - digest = jwk_thumbprint(jwk) - if digest is None: - raise ValueError("incomplete public JWK") - return b64url_encode(digest) - - -def public_directory_key(private_key: PrivateKey) -> dict[str, Any]: - """Export a WBA public JWK, with a thumbprint kid and no purpose claim.""" - public = private_key.public_key() - if isinstance(public, ed25519.Ed25519PublicKey): - from cryptography.hazmat.primitives import serialization - - jwk: dict[str, Any] = { - "kty": "OKP", - "crv": "Ed25519", - "x": b64url_encode( - public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) - ), - } - elif isinstance(public, ec.EllipticCurvePublicKey) and isinstance(public.curve, ec.SECP256R1): - numbers = public.public_numbers() - jwk = { - "kty": "EC", - "crv": "P-256", - "x": b64url_encode(numbers.x.to_bytes(32, "big")), - "y": b64url_encode(numbers.y.to_bytes(32, "big")), - } - else: - raise ValueError("Web Bot Auth supports Ed25519 and P-256 keys") - jwk.update({"kid": thumbprint_key_id(jwk), "use": "sig"}) - return jwk - - -def _algorithm(jwk: Mapping[str, Any]) -> str: - shape = (str(jwk.get("kty")), str(jwk.get("crv"))) - alg = {("OKP", "Ed25519"): "ed25519", ("EC", "P-256"): "ecdsa-p256-sha256"}.get(shape) - if alg is None or jwk.get("use", "sig") != "sig" or "d" in jwk: - raise WbaVerificationError("unsupported directory key") - if jwk.get("alg") not in {None, "EdDSA" if alg == "ed25519" else "ES256"}: - raise WbaVerificationError("directory key algorithm mismatch") - if "key_ops" in jwk and ( - not isinstance(jwk["key_ops"], list) or "verify" not in jwk["key_ops"] - ): - raise WbaVerificationError("directory key cannot verify") - return alg - - -def _base( - method: str, - url: str, - headers: Mapping[str, str], - member: InnerList, - *, - directory_response: bool = False, -) -> bytes: - lines: list[str] = [] - for component in member: - name = component.value - if type(name) is not str: - raise WbaVerificationError("component must be a string", status=400) - params = component.params - if name.startswith("@"): - expected = {"req": True} if directory_response and name == "@authority" else {} - if dict(params) != expected: - raise WbaVerificationError("unsupported derived component parameters") - if name == "@method": - value = method.upper() - elif name == "@target-uri": - value = canonicalize_target_uri(url) - elif name == "@authority": - value = canonicalize_authority(url) - else: - raise WbaVerificationError("unsupported derived component") - else: - raw = _lookup(headers, name) - if raw is None: - raise WbaVerificationError("missing covered field") - if params: - if set(params) != {"key"} or type(params["key"]) is not str: - raise WbaVerificationError("unsupported field component parameters") - dictionary = _dictionary(raw) - if params["key"] not in dictionary: - raise WbaVerificationError("missing covered dictionary member") - value = str(dictionary[params["key"]]) - else: - value = raw.strip() - lines.append(f"{component}: {value}") - lines.append(f'"@signature-params": {member}') - return "\n".join(lines).encode("utf-8") - - -def _window(member: InnerList, *, now: float, directory: bool = False) -> None: - params = member.params - if type(params.get("tag")) is not str: - raise WbaVerificationError("signature tag must be an sf-string") - for name in ("created", "expires"): - if type(params.get(name)) is not int: - raise WbaVerificationError("missing integer time parameter") - created, expires = params["created"], params["expires"] - if expires <= created or (not directory and expires - created > 300): - raise WbaVerificationError("invalid signature lifetime") - if created > now + (0 if directory else 60) or expires < now - (0 if directory else 60): - raise WbaVerificationError("signature outside its validity window") - if type(params.get("keyid")) is not str or len(params["keyid"]) != 43: - raise WbaVerificationError("missing thumbprint keyid") - - -def _verify_crypto(member: InnerList, signature: Any, jwk: Mapping[str, Any], base: bytes) -> str: - alg = _algorithm(jwk) - if member.params.get("alg", alg) != alg or type(member.params.get("alg", alg)) is not str: - raise WbaVerificationError("signature algorithm does not match key") - if not isinstance(signature, Item) or not isinstance(signature.value, bytes): - raise WbaVerificationError("signature must be sf-binary", status=400) - try: - valid = verify_signature( - alg=alg, - public_key=public_key_from_jwk(dict(jwk)), - signature_base=base, - signature=signature.value, - ) - except (ValueError, KeyError, TypeError) as exc: - raise WbaVerificationError("invalid directory key") from exc - if not valid: - raise WbaVerificationError("signature did not verify") - return alg - - -def sign_wba_request( - *, - method: str, - url: str, - headers: Mapping[str, str], - body: bytes, - private_key: PrivateKey, - agent_origin: str, - created: int | None = None, - expires_in_seconds: int = 300, - nonce: str | None = None, - label: str = "sig1", -) -> dict[str, str]: - """Sign with the opt-in WBA profile; infer alg and kid from the public key.""" - origin = _https_origin(agent_origin) - if not 0 < expires_in_seconds <= 300: - raise ValueError("expires_in_seconds must be in (0, 300]") - if any( - _lookup(headers, name) is not None - for name in ("signature", "signature-input", "signature-agent") - ): - raise ValueError("use a relay signer to append to an already signed request") - from adcp.signing.signer import _validate_sf_key - - _validate_sf_key(label, field="label") - jwk = public_directory_key(private_key) - created = int(time.time()) if created is None else created - nonce = b64url_encode(secrets.token_bytes(32)) if nonce is None else nonce - if not 22 <= len(nonce) <= 256: - raise ValueError( - "nonce must carry at least 128 bits of entropy and be at most 256 characters" - ) - outgoing = {"Signature-Agent": str(Dictionary({label: Item(origin)}))} - components = [Item(c) for c in ("@method", "@target-uri", "@authority")] - if body: - if _lookup(headers, "content-type") is None: - raise ValueError("body-bearing WBA requests require Content-Type") - outgoing["Content-Digest"] = compute_content_digest_sha256(body) - components.extend(Item(c) for c in ("content-type", "content-digest")) - agent = Item("signature-agent") - agent.params["key"] = label - components.append(agent) - member = InnerList([c for c in components]) - member.params.update( - { - "created": created, - "expires": created + expires_in_seconds, - "nonce": nonce, - "keyid": jwk["kid"], - "alg": _algorithm(jwk), - "tag": WBA_TAG, - } - ) - signature = sign_signature_base( - alg=_algorithm(jwk), - private_key=private_key, - signature_base=_base( - method, - url, - { - **{k.lower(): v for k, v in headers.items()}, - **{k.lower(): v for k, v in outgoing.items()}, - }, - member, - ), - ) - outgoing["Signature-Input"] = str(Dictionary({label: member})) - outgoing["Signature"] = str(Dictionary({label: Item(signature)})) - return outgoing - - -@dataclass(frozen=True) -class DirectoryResponse: - """Signed directory bytes and headers, retained as evidence of discovery.""" - - body: bytes - headers: Mapping[str, str] - status: int = 200 - - -def verify_directory_response( - origin: str, response: DirectoryResponse, *, now: float -) -> dict[str, dict[str, Any]]: - """Validate every directory key's response signature (WBA Appendix B.1).""" - origin = _https_origin(origin) - media_type = (_lookup(response.headers, "content-type") or "").split(";", 1)[0].strip() - if ( - response.status != 200 - or media_type != DIRECTORY_MEDIA_TYPE - or len(response.body) > 256 * 1024 - ): - raise WbaVerificationError("invalid key directory response") - try: - document = parse_strict_json(response.body) - except StrictJsonError as exc: - raise WbaVerificationError("invalid directory JSON") from exc - keys = document.get("keys") if isinstance(document, dict) else None - if not isinstance(keys, list) or not 1 <= len(keys) <= 100: - raise WbaVerificationError("invalid directory key count") - index: dict[str, dict[str, Any]] = {} - for jwk in keys: - if ( - not isinstance(jwk, dict) - or jwk.get("kid") != thumbprint_key_id(jwk) - or jwk["kid"] in index - ): - raise WbaVerificationError("invalid or duplicate directory thumbprint") - _algorithm(jwk) - index[jwk["kid"]] = jwk - digest = _lookup(response.headers, "content-digest") - if digest is None or not content_digest_matches(digest, response.body): - raise WbaVerificationError("directory digest mismatch") - inputs = _dictionary(_lookup(response.headers, "signature-input")) - signatures = _dictionary(_lookup(response.headers, "signature")) - verified: set[str] = set() - for label, member in inputs.items(): - if not isinstance(member, InnerList) or member.params.get("tag") != DIRECTORY_TAG: - raise WbaVerificationError("invalid directory signature profile") - _window(member, now=now, directory=True) - covered = {str(c) for c in member} - if not {'"@authority";req', '"content-digest"'} <= covered: - raise WbaVerificationError("directory signature does not bind authority and digest") - keyid = member.params["keyid"] - if keyid not in index or keyid in verified: - raise WbaVerificationError("directory signature key is absent or repeated") - _verify_crypto( - member, - signatures.get(label), - index[keyid], - _base( - "GET", origin + DIRECTORY_PATH, response.headers, member, directory_response=True - ), - ) - verified.add(keyid) - if verified != set(index) or set(signatures) != set(inputs): - raise WbaVerificationError("directory requires one signature per key") - return index - - -def sign_key_directory( - *, - agent_origin: str, - private_keys: Sequence[PrivateKey], - created: int | None = None, - expires_in_seconds: int = 86400, -) -> DirectoryResponse: - """Generate a signed directory response; republish before its expires time.""" - origin = _https_origin(agent_origin) - if not 1 <= len(private_keys) <= 100 or expires_in_seconds <= 0: - raise ValueError("directory requires 1–100 keys and a positive lifetime") - keys = [public_directory_key(key) for key in private_keys] - if len({key["kid"] for key in keys}) != len(keys): - raise ValueError("directory keys must be distinct") - body = json.dumps({"keys": keys}, separators=(",", ":")).encode() - headers = { - "Content-Type": DIRECTORY_MEDIA_TYPE, - "Content-Digest": compute_content_digest_sha256(body), - "Cache-Control": "max-age=300", - } - created = int(time.time()) if created is None else created - inputs, signatures = Dictionary(), Dictionary() - for number, (key, jwk) in enumerate(zip(private_keys, keys), 1): - authority = Item("@authority") - authority.params["req"] = True - member = InnerList([authority, Item("content-digest")]) - member.params.update( - { - "created": created, - "expires": created + expires_in_seconds, - "keyid": jwk["kid"], - "alg": _algorithm(jwk), - "tag": DIRECTORY_TAG, - } - ) - label = f"key{number}" - inputs[label] = member - signatures[label] = Item( - sign_signature_base( - alg=_algorithm(jwk), - private_key=key, - signature_base=_base( - "GET", origin + DIRECTORY_PATH, headers, member, directory_response=True - ), - ) - ) - headers.update({"Signature-Input": str(inputs), "Signature": str(signatures)}) - return DirectoryResponse(body=body, headers=headers) - - -class DirectoryFetcher(Protocol): - async def __call__(self, url: str, *, allow_private: bool = False) -> DirectoryResponse: ... - - -async def fetch_key_directory(url: str, *, allow_private: bool = False) -> DirectoryResponse: - """Fetch over TLS with IP pinning, no redirects/proxies, and bounded bytes/time.""" - from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport - - _https_origin(url) - - async def fetch() -> DirectoryResponse: - transport = await asyncio.to_thread( - build_async_ip_pinned_transport, url, allow_private=allow_private - ) - fetch_timeout = httpx.Timeout(10, connect=5) - async with httpx.AsyncClient( - transport=transport, - timeout=fetch_timeout, - follow_redirects=False, - trust_env=False, - ) as client: - async with client.stream( - "GET", url, headers={"Accept": DIRECTORY_MEDIA_TYPE, "Accept-Encoding": "identity"} - ) as response: - return DirectoryResponse( - status=response.status_code, - headers=dict(response.headers), - body=( - await async_read_limited_bytes(response, limit=256 * 1024) - if response.status_code == 200 - else b"" - ), - ) - - return await asyncio.wait_for(fetch(), timeout=10) - - -@dataclass(frozen=True) -class _DirectoryFailure: - reason: str - status: int - unavailable: bool - - def exception(self) -> WbaVerificationError: - return ( - WbaUnavailableError(self.reason) - if self.unavailable - else WbaVerificationError(self.reason, status=self.status) - ) - - -@dataclass -class _DirectoryCache: - response: DirectoryResponse | None - keys: dict[str, dict[str, Any]] - expires: float - retry_after: float = 0 - failure: _DirectoryFailure | None = None - - -class DiscoveredSignerKeys: - """Discover many WBA signers using (covered origin, thumbprint), with bounded caching. - - A fetcher is a trusted test/transport seam and must enforce fetch policy. - This resolver never reads capabilities, brand.json, or trust.json. - """ - - def __init__( - self, - *, - fetcher: DirectoryFetcher = fetch_key_directory, - allow_private: bool = False, - max_origins: int = 256, - max_origins_per_operator: int = 8, - clock: Callable[[], float] = time.time, - legacy_resolver: SignerKeyResolver | None = None, - ) -> None: - if any( - type(limit) is not int or limit <= 0 - for limit in ( - max_origins, - max_origins_per_operator, - ) - ): - raise ValueError("origin admission limits must be positive") - self._fetcher, self._allow_private, self._max_origins, self._clock = ( - fetcher, - allow_private, - max_origins, - clock, - ) - self._cache: OrderedDict[str, _DirectoryCache] = OrderedDict() - self._max_origins_per_operator = max_origins_per_operator - self._failed_discoveries: OrderedDict[str, _DirectoryCache] = OrderedDict() - self._lock = asyncio.Lock() - self._legacy_resolver = legacy_resolver - self._pending: dict[str, asyncio.Task[_DirectoryCache]] = {} - - async def __call__( - self, keyid: str, *, signer_origin: str | None = None - ) -> ResolvedSignerKey | None: - if signer_origin is None: - # Coexistence is explicit and never falls back from the WBA branch. - return await self._legacy_resolver(keyid) if self._legacy_resolver is not None else None - origin = _https_origin(signer_origin) - async with self._lock: - now = self._clock() - cached = self._cache.get(origin) or self._failed_discoveries.get(origin) - if cached is not None and cached.expires > now and cached.response is not None: - self._cache.move_to_end(origin) - key = cached.keys.get(keyid) - return ResolvedSignerKey(origin, dict(key)) if key is not None else None - if cached is not None and cached.retry_after > now: - if cached.failure is not None: - raise cached.failure.exception() - raise WbaUnavailableError("key directory discovery is cooling down") - task = self._pending.get(origin) - if task is None: - admitted = self._cache.keys() | self._pending.keys() - if origin not in admitted and len(admitted) >= self._max_origins: - raise WbaUnavailableError("directory discovery capacity exceeded") - operator = OriginRevocationChecker._operator(origin) - if ( - origin not in admitted - and sum( - OriginRevocationChecker._operator(value) == operator for value in admitted - ) - >= self._max_origins_per_operator - ): - raise WbaUnavailableError("operator directory discovery capacity exceeded") - task = asyncio.create_task(self._refresh(origin, cached)) - # A cancelled HTTP request must not cancel discovery for other - # waiters or leave an unobserved exception in a background task. - task.add_done_callback( - lambda done: done.exception() if not done.cancelled() else None - ) - self._pending[origin] = task - cached = await asyncio.shield(task) - key = cached.keys.get(keyid) - return ResolvedSignerKey(origin, dict(key)) if key is not None else None - - async def _refresh(self, origin: str, previous: _DirectoryCache | None) -> _DirectoryCache: - try: - response = await self._fetcher( - origin + DIRECTORY_PATH, allow_private=self._allow_private - ) - now = self._clock() - if response.status not in (200, 404, 410): - raise WbaUnavailableError("key directory temporarily unavailable") - keys = verify_directory_response(origin, response, now=now) - inputs = _dictionary(_lookup(response.headers, "signature-input")) - expires = min(now + 300, *(float(m.params["expires"]) for m in inputs.values())) - fresh = _DirectoryCache(response, keys, expires) - async with self._lock: - self._cache[origin] = fresh - self._cache.move_to_end(origin) - self._failed_discoveries.pop(origin, None) - return fresh - except Exception as exc: - failure = ( - exc - if isinstance(exc, WbaVerificationError) - else ( - WbaVerificationError("invalid key directory response") - if isinstance(exc, ValueError) - else WbaUnavailableError("key directory discovery failed") - ) - ) - # Keep the previous signed snapshot as evidence. Expired keys never - # authenticate requests, and a fetch failure never proves removal. - async with self._lock: - failed = previous or _DirectoryCache(None, {}, 0) - failed.retry_after = self._clock() + 60 - failed.failure = _DirectoryFailure( - failure.reason, failure.status, isinstance(failure, WbaUnavailableError) - ) - if failed.response is not None: - self._cache[origin] = failed - else: - self._failed_discoveries[origin] = failed - self._failed_discoveries.move_to_end(origin) - while len(self._failed_discoveries) > self._max_origins: - self._failed_discoveries.popitem(last=False) - if failure is exc: - raise - raise failure from exc - finally: - self._pending.pop(origin, None) - - -async def verify_wba_request( - *, - method: str, - url: str, - headers: Mapping[str, str], - body: bytes, - signer_keys: WbaSignerKeyResolver, - replay_store: ReplayStore | None, - revocation_checker: Any, - now: float | None = None, - raw_headers: Sequence[tuple[bytes, bytes]] | None = None, -) -> tuple[VerifiedSigner, ...]: - """Verify all WBA signatures and relay coverage; return established origins only.""" - if replay_store is None: - raise ValueError("Web Bot Auth requires a replay store") - if revocation_checker is None: - raise ValueError("Web Bot Auth requires an origin revocation checker") - now = time.time() if now is None else now - if strict_header_precheck(headers=headers, raw_headers=raw_headers, url=url) is not None: - raise WbaVerificationError("ambiguous signature fields", status=400) - if raw_headers is not None: - raw_agents = [v for k, v in raw_headers if k.lower() == b"signature-agent"] - if len(raw_agents) > 1: - raise WbaVerificationError("repeated Signature-Agent field", status=400) - inputs = _dictionary(_lookup(headers, "signature-input")) - signatures = _dictionary(_lookup(headers, "signature")) - agents = _dictionary(_lookup(headers, "signature-agent")) - wba = [ - (label, member) - for label, member in inputs.items() - if isinstance(member, InnerList) and member.params.get("tag") == WBA_TAG - ] - if not wba: - raise WbaVerificationError("Signature-Agent requires the Web Bot Auth profile") - if set(signatures) != set(inputs): - raise WbaVerificationError("signature labels do not match", status=400) - verified: list[tuple[VerifiedSigner, str, float]] = [] - earlier: list[str] = [] - for label, member in wba: - _window(member, now=now) - nonce = member.params.get("nonce") - if type(nonce) is not str or not 22 <= len(nonce) <= 256: - raise WbaVerificationError("missing or invalid nonce", label=label) - agent = agents.get(label) - if ( - not isinstance(agent, Item) - or type(agent.value) is not str - or agent.params.get("type", "directory") != "directory" - ): - raise WbaVerificationError("missing supported Signature-Agent member", label=label) - try: - origin = _https_origin(agent.value) - except ValueError as exc: - raise WbaVerificationError("invalid signer origin", label=label) from exc - covered = {str(c) for c in member} - required = { - '"@method"', - '"@target-uri"', - '"@authority"', - f'"signature-agent";key="{label}"', - } - if body: - required.update({'"content-type"', '"content-digest"'}) - for previous in earlier: - required.update( - f'"{field}";key="{previous}"' - for field in ("signature-agent", "signature-input", "signature") - ) - # RFC 9421 §2.2.8 also requires all components of the signature - # being covered, including application-specific header fields. - required.update(str(component) for component in inputs[previous]) - if not required <= covered or len(covered) != len(member): - raise WbaVerificationError("missing or repeated covered component", label=label) - keyid = member.params["keyid"] - try: - key = await signer_keys(keyid, signer_origin=origin) - except WbaVerificationError: - raise - except Exception as exc: - raise WbaUnavailableError("signer key resolution unavailable") from exc - if ( - key is None - or canonical_origin(key.agent_url) != origin - or thumbprint_key_id(key.jwk) != keyid - ): - raise WbaVerificationError("key absent from the named origin", label=label) - alg = _verify_crypto( - member, signatures.get(label), key.jwk, _base(method, url, headers, member) - ) - if body: - digest = _lookup(headers, "content-digest") - if digest is None or not content_digest_matches(digest, body): - raise WbaVerificationError("request digest mismatch", label=label) - try: - revoked = await revocation_checker(origin, keyid) - except WbaVerificationError: - raise - except Exception as exc: - raise WbaUnavailableError("origin revocation status unavailable") from exc - if revoked: - raise WbaAuthorizationError("signing key revoked") - verified.append( - ( - VerifiedSigner( - key_id=keyid, - alg=alg, - label=label, - verified_at=now, - agent_url=origin, - body_authenticated=bool(body), - profile=WBA_TAG, - ), - nonce, - member.params["expires"] - now + 60, - ) - ) - earlier.append(label) - for signer, nonce, ttl in verified: - claim = await asyncio.to_thread( - _claim_replay_nonce, replay_store, signer.agent_url or "", nonce, max(ttl, 0) - ) - if claim != "claimed": - raise WbaVerificationError( - "replayed nonce" if claim == "replayed" else "replay store capacity exceeded", - status=403 if claim == "replayed" else 429, - label=signer.label, - ) - try: - parsed = parse_strict_json(body) if body.strip() else None - except StrictJsonError as exc: - raise WbaVerificationError("malformed signed JSON body", status=400) from exc - from dataclasses import replace - - return tuple(replace(signer, parsed_body=parsed) for signer, _, _ in verified) - - -__all__ = [ - "OriginRevocationChecker", - "WbaAuthorizationError", - "WbaUnavailableError", - "DIRECTORY_MEDIA_TYPE", - "DirectoryResponse", - "DiscoveredSignerKeys", - "WbaVerificationError", - "public_directory_key", - "sign_key_directory", - "sign_wba_request", - "thumbprint_key_id", - "uses_web_bot_auth", - "verify_directory_response", - "verify_wba_request", -] - - -class OriginRevocationChecker: - """Verify origin revocation lists with keys from that origin's signed directory. - - Retains successful checker state so revoked keys never become usable again. - Failed first discoveries are cooled down in an evictable cache. Admission - is bounded globally and per operator; larger deployments should supply a - durable checker with their own principal admission policy. - """ - - def __init__( - self, - signer_keys: WbaSignerKeyResolver, - *, - fetcher: Any = None, - max_origins: int = 256, - max_origins_per_operator: int = 8, - clock: Callable[[], float] = time.monotonic, - wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), - max_facts_per_origin: int = 2048, - max_facts_per_operator: int = 8192, - max_history_facts: int = 65536, - ) -> None: - if any( - type(limit) is not int or limit <= 0 - for limit in ( - max_origins, - max_origins_per_operator, - max_facts_per_origin, - max_facts_per_operator, - max_history_facts, - ) - ): - raise ValueError("origin admission limits must be positive") - self._signer_keys = signer_keys - self._fetcher = fetcher - self._max_origins = max_origins - self._max_origins_per_operator = max_origins_per_operator - self._clock = clock - self._wall_clock = wall_clock - self._max_facts_per_origin = max_facts_per_origin - self._max_facts_per_operator = max_facts_per_operator - self._max_history_facts = max_history_facts - self._revoked_facts: dict[str, set[tuple[str, str]]] = {} - self._operator_fact_counts: dict[str, int] = {} - self._history_fact_count = 0 - self._checkers: dict[str, Any] = {} - self._pending: dict[str, asyncio.Task[Any]] = {} - self._failures: OrderedDict[str, float] = OrderedDict() - self._lock = asyncio.Lock() - - async def __call__(self, origin: str, keyid: str) -> bool: - origin = _https_origin(origin) - async with self._lock: - checker = self._checkers.get(origin) - task = None - if checker is None: - if self._failures.get(origin, 0) > self._clock(): - self._failures.move_to_end(origin) - raise WbaUnavailableError("origin revocation discovery is cooling down") - task = self._pending.get(origin) - if task is None: - admitted_origins = self._checkers.keys() | self._pending.keys() - if len(admitted_origins) >= self._max_origins: - raise WbaUnavailableError("origin revocation capacity exceeded") - operator = self._operator(origin) - admitted = sum( - self._operator(admitted_origin) == operator - for admitted_origin in admitted_origins - ) - if admitted >= self._max_origins_per_operator: - raise WbaUnavailableError("operator revocation capacity exceeded") - task = asyncio.create_task(self._discover(origin)) - task.add_done_callback( - lambda done: done.exception() if not done.cancelled() else None - ) - self._pending[origin] = task - if task is not None: - checker = await asyncio.shield(task) - assert checker is not None - try: - return bool(await checker(keyid)) - except Exception as exc: - raise WbaUnavailableError("origin revocation status unavailable") from exc - - @staticmethod - def _operator(origin: str) -> str: - from adcp.signing.etld import registrable_domain - - # Origins without a registrable domain cannot bind through trust.json, - # but still share a hostname bucket for anonymous identity discovery. - return registrable_domain(origin) or urlsplit(origin).hostname or origin - - def _admit_revocations(self, record: Any) -> bool: - # Called synchronously after authentication and schema validation. - # No await separates checking shared counters from committing them. - origin = record.issuer - operator = self._operator(origin) - known = self._revoked_facts.get(origin, set()) - facts = {("kid", value) for value in record.revoked_kids} | { - ("jti", value) for value in record.revoked_jtis - } - added = len(facts - known) - if ( - len(known | facts) > self._max_facts_per_origin - or self._operator_fact_counts.get(operator, 0) + added > self._max_facts_per_operator - or self._history_fact_count + added > self._max_history_facts - ): - return False - self._revoked_facts[origin] = known | facts - self._operator_fact_counts[operator] = self._operator_fact_counts.get(operator, 0) + added - self._history_fact_count += added - return True - - async def _discover(self, origin: str) -> Any: - from adcp.signing.revocation_fetcher import AsyncCachingRevocationChecker - - checker = None - try: - - async def resolve(keyid: str) -> dict[str, Any] | None: - resolved = await self._signer_keys(keyid, signer_origin=origin) - return dict(resolved.jwk) if resolved is not None else None - - checker = AsyncCachingRevocationChecker.from_issuer_origin( - origin, - jwks_resolver=resolve, - fetcher=self._fetcher, - grace_multiplier=4, - clock=self._clock, - wall_clock=self._wall_clock, - history_admission=self._admit_revocations, - ) - # Authenticated revocations are permanent history. A 404 or - # transport failure with no signed evidence consumes no slot. - await checker.aprime() - async with self._lock: - self._checkers[origin] = checker - self._failures.pop(origin, None) - return checker - except Exception as exc: - async with self._lock: - if checker is not None and checker.has_verified_list: - # Freshness failure denies this request but cannot erase - # revocations learned from the authenticated document. - self._checkers[origin] = checker - self._failures[origin] = self._clock() + 60 - self._failures.move_to_end(origin) - while len(self._failures) > self._max_origins: - self._failures.popitem(last=False) - raise WbaUnavailableError("origin revocation status unavailable") from exc - finally: - async with self._lock: - self._pending.pop(origin, None) diff --git a/src/adcp/types/_identity_preview.py b/src/adcp/types/_identity_preview.py deleted file mode 100644 index ddc6324a5..000000000 --- a/src/adcp/types/_identity_preview.py +++ /dev/null @@ -1,1541 +0,0 @@ -# generated by datamodel-codegen: -# filename: identity-preview.json - -from __future__ import annotations - -from enum import Enum -from typing import Annotated, Any, Literal - -from pydantic import ( - AnyUrl, - AwareDatetime, - BaseModel, - ConfigDict, - EmailStr, - Field, - RootModel, -) - - -class EnumsAgentOnboardingMode(str, Enum): - """ - How a seller onboards buyer agents for one billing party or for sandbox access. Absent means undeclared, never invite_only. - """ - - open = "open" - application = "application" - invite_only = "invite_only" - - -class EnumsAgentOnboardingRequirementKind(str, Enum): - """ - What a counterparty must satisfy before an onboarding mode applies. Each kind states the direction it applies to; a kind used in the other direction is ignored. Readers MUST treat an unknown kind as unsatisfiable by machine and fall back to the posture's message and url. - """ - - agent_identity = "agent_identity" - grant = "grant" - billing_entity = "billing_entity" - contract = "contract" - credit_application = "credit_application" - aao_verified = "aao_verified" - storyboard = "storyboard" - sandbox = "sandbox" - experimental_feature = "experimental_feature" - - -class CoreExt(BaseModel): - """ - Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization. - """ - - model_config = ConfigDict( - extra="allow", - ) - - -class CoreAgentOnboardingRequirement(BaseModel): - """ - One requirement an onboarding mode imposes, discriminated by kind. Only aao_verified, storyboard, and experimental_feature carry fields; other kinds are the kind alone. The requirement describes the gate the publishing party applies on its own side; it creates no conformance obligation on the counterparty. - """ - - model_config = ConfigDict( - extra="allow", - ) - kind: Annotated[EnumsAgentOnboardingRequirementKind | str, Field(union_mode="left_to_right")] - role: Annotated[ - str | None, Field(description="aao_verified: the badge role required, such as media-buy.") - ] = None - verification_modes: Annotated[ - list[str] | None, - Field( - description="aao_verified: accepted verification modes, as the badge token names them.", - min_length=1, - ), - ] = None - specialisms: Annotated[ - list[str] | None, - Field(description="aao_verified: specialisms the badge must include.", min_length=1), - ] = None - adcp_version: Annotated[ - str | None, Field(description="aao_verified: the AdCP release the badge must cover.") - ] = None - id: Annotated[ - str | None, - Field( - description="storyboard: the storyboard or specialism id. experimental_feature: the feature id." - ), - ] = None - ext: CoreExt | None = None - - -class CoreAgentOnboardingTier(BaseModel): - """ - The onboarding mode for one billing party, or for sandbox access, with its requirements and an optional page of its own. - """ - - model_config = ConfigDict( - extra="allow", - ) - mode: EnumsAgentOnboardingMode - url: Annotated[ - AnyUrl | None, - Field( - description="Where a human starts for this mode. Defaults to the posture's top-level url. Untrusted: surface it, never fetch or execute it." - ), - ] = None - requirements: Annotated[ - list[CoreAgentOnboardingRequirement] | None, - Field(description="What the counterparty must satisfy for this mode."), - ] = None - ext: CoreExt | None = None - - -class EnumsBillingParty(str, Enum): - """ - Which party the seller invoices for a buyer–storefront account relationship. Determines the billing entity and invoicing flow, not a payment rail, clearing intermediary, or per-media-buy settlement route. AdCP does not perform in-protocol payment or settlement. - """ - - operator = "operator" - agent = "agent" - advertiser = "advertiser" - - -class CoreAgentOnboarding(BaseModel): - """ - How a seller onboards buyer agents: a mode per billing party and for sandbox, with requirements, and where a human starts. Seller-wide and identical for every caller, served on the unauthenticated get_adcp_capabilities fetch, and absent from error payloads, so it cannot act as a per-agent onboarding oracle. Its content is advisory; it changes no error code and no oracle clamp. An absent key means undeclared, never invite_only. See specs/agent-identity-3-3.md R7 (#8113). - """ - - model_config = ConfigDict( - extra="allow", - ) - sandbox: Annotated[ - CoreAgentOnboardingTier | None, - Field( - description="Onboarding for sandbox accounts. Valid only when account.sandbox is true. Sandbox carries no spend, so open here grants identity and sandbox access only." - ), - ] = None - billing: Annotated[ - dict[EnumsBillingParty, CoreAgentOnboardingTier] | None, - Field( - description="Onboarding per billing party, keyed by billing-party values. Keys MUST be a subset of account.supported_billing. Under open, spend needs an existing seller-side billing relationship with the billed party: the operator domain under operator, the brand's domain under advertiser." - ), - ] = None - url: Annotated[ - AnyUrl | None, - Field( - description="Where a human at the buyer starts when a mode has no url of its own. Untrusted: surface it, never fetch or execute it." - ), - ] = None - documentation_url: Annotated[ - AnyUrl | None, - Field( - description="The seller's onboarding and integration documentation. Untrusted, like url." - ), - ] = None - message: Annotated[ - str | None, - Field( - description="Human-readable summary of the process and what to have ready. The fallback whenever a reader does not understand a requirement." - ), - ] = None - ext: CoreExt | None = None - - -class EnumsBuyerOnboardingMode(str, Enum): - """ - How a buyer considers sellers for its plans. Distinct from the seller-side agent-onboarding-mode, whose open means something else. Absent means undeclared. - """ - - considers_unsolicited = "considers_unsolicited" - application = "application" - invite_only = "invite_only" - - -class CoreBuyerOnboardingPosture(BaseModel): - """ - How a buying agent considers sellers: a mode, the requirements a seller must meet, and where a seller's human starts. Published in the buyer's trust.json agent entry (profiles.adcp.onboarding), or as a record-level default (record_profiles.adcp.onboarding). Public and identical for every reader. A buyer that serves get_adcp_capabilities is authoritative there; trust.json may mirror it. See specs/agent-identity-3-3.md R7 (#8114). - """ - - model_config = ConfigDict( - extra="allow", - ) - mode: EnumsBuyerOnboardingMode - requirements: Annotated[ - list[CoreAgentOnboardingRequirement] | None, - Field( - description="What a seller must satisfy. Uses the kinds that apply when a buyer gates a seller (aao_verified, storyboard, sandbox, experimental_feature); others are ignored." - ), - ] = None - url: Annotated[ - AnyUrl | None, - Field( - description="Where a seller's human starts. Untrusted: surface it, never fetch or execute it." - ), - ] = None - documentation_url: Annotated[ - AnyUrl | None, - Field(description="The buyer's documentation for sellers. Untrusted, like url."), - ] = None - message: Annotated[ - str | None, - Field( - description="Human-readable summary; the fallback whenever a reader does not understand a requirement." - ), - ] = None - ext: CoreExt | None = None - - -class Status(str, Enum): - """ - Current state of the relationship. - """ - - pending = "pending" - active = "active" - suspended = "suspended" - blocked = "blocked" - - -class Setup(BaseModel): - """ - The caller's next step while status is pending, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide. - """ - - model_config = ConfigDict( - extra="allow", - ) - url: Annotated[ - AnyUrl | None, - Field( - description="URL where a human at the buyer completes the step. Untrusted: surface it, never fetch or execute it." - ), - ] = None - message: Annotated[str, Field(description="Human-readable description of what is needed.")] - expires_at: Annotated[AwareDatetime | None, Field(description="When this step expires.")] = None - - -class CorePrincipalRelationship(BaseModel): - """ - The seller's commercial relationship with the authenticated caller, returned by get_principal only to that caller. Present when a relationship or an application exists; omitted when the seller has no relationship with the caller and no application in progress, which is the identity-established state of a verified signer the seller has not bound. Because it is returned only to the authenticated caller, it never acts as an onboarding oracle. See specs/agent-identity-3-3.md R8. - """ - - model_config = ConfigDict( - extra="allow", - ) - status: Annotated[Status, Field(description="Current state of the relationship.")] - expires_at: Annotated[ - AwareDatetime | None, - Field( - description="When the relationship lapses unless renewed. Absent means no scheduled expiry. principal.changed fires with reason relationship_expiring at a seller-declared lead time before this instant." - ), - ] = None - setup: Annotated[ - Setup | None, - Field( - description="The caller's next step while status is pending, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide." - ), - ] = None - ext: CoreExt | None = None - - -class EnumsRightUse(str, Enum): - """ - Types of rights usage that can be licensed through the brand protocol. Aligned with DDEX UseType direction for interoperability with music and media rights systems. - """ - - likeness = "likeness" - voice = "voice" - name = "name" - endorsement = "endorsement" - motion_capture = "motion_capture" - signature = "signature" - catchphrase = "catchphrase" - sync = "sync" - background_music = "background_music" - editorial = "editorial" - commercial = "commercial" - ai_generated_image = "ai_generated_image" - - -class EnumsRightType(str, Enum): - """ - Categories of intellectual property rights that can be licensed through the brand protocol. - """ - - talent = "talent" - character = "character" - brand_ip = "brand_ip" - music = "music" - stock_media = "stock_media" - - -class CoreBrandId(RootModel[str]): - root: Annotated[ - str, - Field( - description="Identifier for a brand within a house portfolio. Must be lowercase alphanumeric with underscores only. The house chooses this identifier.", - examples=["tide", "cheerios", "air_jordan", "nike", "pampers"], - pattern="^[a-z0-9_]+$", - title="Brand ID", - ), - ] - - -class EnumsDigitalSourceType(str, Enum): - """ - Classification of AI involvement in content creation, aligned with IPTC digitalsourcetype vocabulary. Used in provenance metadata to declare how a creative asset, artifact, or individual asset was produced. - """ - - digital_capture = "digital_capture" - digital_creation = "digital_creation" - trained_algorithmic_media = "trained_algorithmic_media" - composite_with_trained_algorithmic_media = "composite_with_trained_algorithmic_media" - algorithmic_media = "algorithmic_media" - composite_capture = "composite_capture" - composite_synthetic = "composite_synthetic" - human_edits = "human_edits" - data_driven_media = "data_driven_media" - - -class EnumsEmbeddedProvenanceMethod(str, Enum): - """ - How provenance data is carried within the content stream. Distinguishes between format-level manifest embedding (where the manifest travels inside the file container) and provenance markers (where invisible markers in the content itself encode or reference a provenance record). Both survive operations that strip sidecar metadata, but provenance markers additionally survive content-level transformations such as copy-paste, reformatting, and CMS re-encoding. - """ - - manifest_wrapper = "manifest_wrapper" - provenance_markers = "provenance_markers" - - -class EnumsWatermarkMediaType(str, Enum): - """ - Media category of a content watermark. Identifies what kind of content the watermark is applied to, which determines the detection and verification methods required. - """ - - audio = "audio" - image = "image" - video = "video" - text = "text" - - -class EnumsC2paWatermarkAction(str, Enum): - """ - C2PA action classification for a content watermark. Distinguishes watermarks that are cryptographically linked to a C2PA manifest (bound) from watermarks that exist independently (unbound). Aligns with the c2pa.watermarked.* action family in the C2PA action taxonomy. - """ - - c2pa_watermarked_bound = "c2pa.watermarked.bound" - c2pa_watermarked_unbound = "c2pa.watermarked.unbound" - - -class EnumsDisclosurePersistence(str, Enum): - """ - How long a disclosure must persist during content playback or display. Different jurisdictions and regulations require different persistence behaviors for AI-generated content labels. When multiple sources specify persistence for the same jurisdiction (e.g., brief and provenance), the most restrictive mode applies: continuous > initial > flexible. - """ - - continuous = "continuous" - initial = "initial" - flexible = "flexible" - - -class EnumsDisclosurePosition(str, Enum): - """ - Where a required disclosure should appear within a creative. Used by creative briefs to specify disclosure placement and by formats to declare which positions they can render. - """ - - prominent = "prominent" - footer = "footer" - audio = "audio" - subtitle = "subtitle" - overlay = "overlay" - end_card = "end_card" - pre_roll = "pre_roll" - companion = "companion" - - -class AiTool(BaseModel): - """ - AI system used to generate or modify this content. Aligns with IPTC 2025.1 AI metadata fields and C2PA claim_generator. - """ - - name: Annotated[ - str, - Field( - description="Name of the AI tool or model (e.g., 'DALL-E 3', 'Stable Diffusion XL', 'Gemini')" - ), - ] - version: Annotated[ - str | None, - Field( - description="Version identifier for the AI tool or model (e.g., '25.1', '0125', '2.1'). For generative models, use the model version rather than the API version." - ), - ] = None - provider: Annotated[ - str | None, - Field( - description="Organization that provides the AI tool (e.g., 'OpenAI', 'Stability AI', 'Google')" - ), - ] = None - - -class HumanOversight(str, Enum): - """ - Level of human involvement in the AI-assisted creation process. Independent of `disclosure.required` — the protocol does not derive disclosure obligations from oversight level. Some regulations include carve-outs for human-edited or human-directed AI output, but those carve-outs have factual prerequisites the schema cannot evaluate. Asserting `edited` or `directed` does not by itself justify `disclosure.required: false`. - """ - - none = "none" - prompt_only = "prompt_only" - selected = "selected" - edited = "edited" - directed = "directed" - - -class Role(str, Enum): - """ - Role of the declaring party in the supply chain - """ - - creator = "creator" - advertiser = "advertiser" - agency = "agency" - platform = "platform" - tool = "tool" - - -class DeclaredBy(BaseModel): - """ - Party declaring this provenance. Identifies who attached the provenance claim, enabling receiving parties to assess trust. - """ - - agent_url: Annotated[ - AnyUrl | None, - Field(description="URL of the agent or service that declared this provenance"), - ] = None - role: Annotated[Role, Field(description="Role of the declaring party in the supply chain")] - - -class C2pa(BaseModel): - """ - C2PA sidecar manifest reference. Links to a detached cryptographic provenance manifest for this content. Note: file-level C2PA bindings break when ad servers transcode, resize, or re-encode assets. For pipelines with intermediaries, consider embedded_provenance as the primary provenance mechanism. - """ - - manifest_url: Annotated[ - AnyUrl, Field(description="URL to the C2PA manifest store for this content") - ] - - -class VerifyAgent(BaseModel): - """ - Buyer's representation that this embedding can be verified by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). MAY be omitted for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts). - """ - - model_config = ConfigDict( - extra="forbid", - ) - agent_url: Annotated[ - AnyUrl, - Field( - description="URL of the governance agent the buyer represents was used to embed/verify this layer. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." - ), - ] - feature_id: Annotated[ - str | None, - Field( - description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `encypher.markers_present_v2`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." - ), - ] = None - - -class EmbeddedProvenanceItem(BaseModel): - method: Annotated[ - EnumsEmbeddedProvenanceMethod, - Field(description="How provenance data is carried within the content"), - ] - standard: Annotated[ - str | None, - Field( - description="Standard the embedding conforms to, if any (e.g., 'c2pa' for C2PA Section A.7 text manifest embedding)" - ), - ] = None - provider: Annotated[ - str, - Field( - description="Organization that performed the embedding (e.g., 'Encypher', 'Digimarc'). Display label and audit context — not a wire identifier." - ), - ] - verify_agent: Annotated[ - VerifyAgent | None, - Field( - description="Buyer's representation that this embedding can be verified by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). MAY be omitted for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts)." - ), - ] = None - embedded_at: Annotated[ - AwareDatetime | None, Field(description="When the provenance data was embedded (ISO 8601)") - ] = None - - -class VerifyAgent1(BaseModel): - """ - Buyer's representation that this watermark can be detected by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). - """ - - model_config = ConfigDict( - extra="forbid", - ) - agent_url: Annotated[ - AnyUrl, - Field( - description="URL of the governance agent the buyer represents was used to apply/detect this watermark. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." - ), - ] - feature_id: Annotated[ - str | None, - Field( - description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `imatag.watermark_detected`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." - ), - ] = None - - -class Watermark(BaseModel): - media_type: Annotated[ - EnumsWatermarkMediaType, Field(description="Media category of the watermarked content") - ] - provider: Annotated[ - str, - Field( - description="Organization that applied the watermark (e.g., 'Imatag', 'Steg.AI', 'Encypher'). Display label and audit context — not a wire identifier." - ), - ] - verify_agent: Annotated[ - VerifyAgent1 | None, - Field( - description="Buyer's representation that this watermark can be detected by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist)." - ), - ] = None - c2pa_action: Annotated[ - EnumsC2paWatermarkAction | None, - Field(description="C2PA action classification for this watermark"), - ] = None - embedded_at: Annotated[ - AwareDatetime | None, Field(description="When the watermark was applied (ISO 8601)") - ] = None - - -class RenderGuidance(BaseModel): - """ - How the disclosure should be rendered for this jurisdiction. Expresses the declaring party's intent for persistence and position based on regulatory requirements. Publishers control actual rendering but governance agents can audit whether guidance was followed. - """ - - persistence: Annotated[ - EnumsDisclosurePersistence | None, - Field( - description="How long the disclosure must persist during content playback or display" - ), - ] = None - min_duration_ms: Annotated[ - int | None, - Field( - description="Minimum display duration in milliseconds for initial persistence. Recommended when persistence is initial — without it, the duration is at the publisher's discretion. At serve time the publisher reads this from provenance since the brief is not available.", - ge=1, - ), - ] = None - positions: Annotated[ - list[EnumsDisclosurePosition] | None, - Field( - description="Preferred disclosure positions in priority order. The first position a format supports should be used.", - min_length=1, - ), - ] = None - ext: CoreExt | None = None - - -class Jurisdiction(BaseModel): - country: Annotated[ - str, Field(description="ISO 3166-1 alpha-2 country code (e.g., 'US', 'DE', 'CN')") - ] - region: Annotated[ - str | None, - Field(description="Sub-national region code (e.g., 'CA' for California, 'BY' for Bavaria)"), - ] = None - regulation: Annotated[ - str, - Field( - description="Regulation identifier (e.g., 'eu_ai_act_article_50', 'ca_sb_942', 'cn_deep_synthesis')" - ), - ] - label_text: Annotated[ - str | None, - Field( - description="Required disclosure label text for this jurisdiction, in the local language" - ), - ] = None - render_guidance: Annotated[ - RenderGuidance | None, - Field( - description="How the disclosure should be rendered for this jurisdiction. Expresses the declaring party's intent for persistence and position based on regulatory requirements. Publishers control actual rendering but governance agents can audit whether guidance was followed." - ), - ] = None - - -class Disclosure(BaseModel): - """ - Regulatory disclosure requirements for this content. Indicates whether AI disclosure is required and under which jurisdictions. - """ - - required: Annotated[ - bool, - Field( - description="The declaring party's claim that AI disclosure is required for this content under applicable regulations. This is a declared signal carried through the supply chain — useful as a routing and audit input — not a regulatory determination made by the protocol. Receiving parties remain responsible for their own jurisdictional analysis and should not treat `required: false` as compliance cover." - ), - ] - jurisdictions: Annotated[ - list[Jurisdiction] | None, - Field(description="Jurisdictions where disclosure obligations apply", min_length=1), - ] = None - - -class Result(str, Enum): - """ - Verification outcome - """ - - authentic = "authentic" - ai_generated = "ai_generated" - ai_modified = "ai_modified" - inconclusive = "inconclusive" - - -class VerificationItem(BaseModel): - verified_by: Annotated[ - str, - Field( - description="Name of the verification service (e.g., 'DoubleVerify', 'Hive Moderation', 'Reality Defender')" - ), - ] - verified_time: Annotated[ - AwareDatetime | None, Field(description="When the verification was performed (ISO 8601)") - ] = None - result: Annotated[Result, Field(description="Verification outcome")] - confidence: Annotated[ - float | None, - Field( - description="Confidence score of the verification result (0.0 to 1.0)", ge=0.0, le=1.0 - ), - ] = None - details_url: Annotated[ - AnyUrl | None, Field(description="URL to the full verification report") - ] = None - - -class CoreProvenance(BaseModel): - """ - Declares how content was produced, whether AI was involved, and what disclosure obligations apply. Attaches to creative manifests, individual assets, or content-standards artifacts. When present at multiple levels, the most specific provenance object replaces the inherited one entirely (no field-level merging). Provenance is a claim by the declaring party — receiving parties should verify claims independently via their own detection tools. - """ - - digital_source_type: Annotated[ - EnumsDigitalSourceType | None, - Field( - description="IPTC-aligned classification of AI involvement in producing this content" - ), - ] = None - synthetic_depiction: Annotated[ - bool | None, - Field( - description="Assessed declaration of whether the content synthetically depicts a real or fictional person performing or appearing in a way that was generated or materially manipulated rather than captured as depicted. `true` covers both a fully synthetic performer and material manipulation of a real performer; `false` is an assessed declaration that the content does not contain such a depiction. Absence means the content has not been assessed for synthetic depiction. This field does not claim consent, legality, or independent verification, and receivers MUST NOT derive it solely from `digital_source_type`." - ), - ] = None - ai_tool: Annotated[ - AiTool | None, - Field( - description="AI system used to generate or modify this content. Aligns with IPTC 2025.1 AI metadata fields and C2PA claim_generator." - ), - ] = None - human_oversight: Annotated[ - HumanOversight | None, - Field( - description="Level of human involvement in the AI-assisted creation process. Independent of `disclosure.required` — the protocol does not derive disclosure obligations from oversight level. Some regulations include carve-outs for human-edited or human-directed AI output, but those carve-outs have factual prerequisites the schema cannot evaluate. Asserting `edited` or `directed` does not by itself justify `disclosure.required: false`." - ), - ] = None - declared_by: Annotated[ - DeclaredBy | None, - Field( - description="Party declaring this provenance. Identifies who attached the provenance claim, enabling receiving parties to assess trust." - ), - ] = None - declared_at: Annotated[ - AwareDatetime | None, - Field( - description="When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance." - ), - ] = None - created_time: Annotated[ - AwareDatetime | None, - Field(description="When this content was created or generated (ISO 8601)"), - ] = None - c2pa: Annotated[ - C2pa | None, - Field( - description="C2PA sidecar manifest reference. Links to a detached cryptographic provenance manifest for this content. Note: file-level C2PA bindings break when ad servers transcode, resize, or re-encode assets. For pipelines with intermediaries, consider embedded_provenance as the primary provenance mechanism." - ), - ] = None - embedded_provenance: Annotated[ - list[EmbeddedProvenanceItem] | None, - Field( - description="Provenance metadata embedded within the content stream. Each entry declares one embedding layer: structured provenance data carried inside the content itself, as distinct from sidecar references (c2pa.manifest_url). Embedded provenance survives operations that break sidecar and file-level bindings: ad-server transcoding, CMS ingestion, copy-paste, reformatting, and CDN re-encoding. For ad-tech pipelines where content passes through multiple intermediaries, embedded provenance is the reliable path for provenance that persists from declaration through delivery. This is a declaration by the embedding party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).", - min_length=1, - ), - ] = None - watermarks: Annotated[ - list[Watermark] | None, - Field( - description="Content watermarks applied to this asset. Each entry declares one watermarking layer: a content modification that encodes an identifier or fingerprint within the asset. Watermarks differ from embedded provenance: a watermark encodes an identifier (who generated it, who owns it), while embedded provenance carries or references a structured provenance record (the full chain of custody). A single asset may carry both. Aligns with C2PA action taxonomy: c2pa.watermarked.bound (watermark linked to a C2PA manifest) and c2pa.watermarked.unbound (watermark independent of any manifest). This is a declaration by the watermarking party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).", - min_length=1, - ), - ] = None - disclosure: Annotated[ - Disclosure | None, - Field( - description="Regulatory disclosure requirements for this content. Indicates whether AI disclosure is required and under which jurisdictions." - ), - ] = None - verification: Annotated[ - list[VerificationItem] | None, - Field( - description="Third-party verification or detection results for this content. Multiple services may independently evaluate the same content. Provenance is a claim — verification results attached by the declaring party are supplementary. The enforcing party (e.g., seller/publisher) should run its own verification via get_creative_features or calibrate_content.", - min_length=1, - ), - ] = None - ext: CoreExt | None = None - - -class FocalPointItem(RootModel[float]): - root: Annotated[float, Field(ge=0.0, le=1.0)] - - -class CoreAssetsImageAsset(BaseModel): - """ - Image asset with URL and dimensions - """ - - model_config = ConfigDict( - extra="allow", - ) - asset_type: Annotated[ - Literal["image"], - Field( - description="Discriminator identifying this as an image asset. See /schemas/creative/asset-types for the registry." - ), - ] - url: Annotated[AnyUrl, Field(description="URL to the image asset")] - width: Annotated[int, Field(description="Width in pixels", ge=1)] - height: Annotated[int, Field(description="Height in pixels", ge=1)] - file_size_bytes: Annotated[ - int | None, - Field( - description="Image file size in bytes. Required by agents that advertise a max_file_size_kb constraint.", - ge=1, - ), - ] = None - pixel_ratio: Annotated[ - float | None, - Field( - description="Intrinsic pixels per logical render pixel (for example `2` for a 600×500 image intended to render at 300×250). Optional because a validator can infer the ratio when the target format declares logical dimensions. When supplied, it MUST agree with both `width / logical_width` and `height / logical_height`; it is never a substitute for the intrinsic `width` and `height` fields.", - gt=0.0, - ), - ] = None - state_id: Annotated[ - str | None, - Field( - description="Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one declared `states[].state_id` (semantic validators resolve it). Omit for ordinary image slots." - ), - ] = None - breakpoint_id: Annotated[ - str | None, - Field( - description="Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one breakpoint declared on the selected state (semantic validators resolve it). Omit for ordinary image slots." - ), - ] = None - focal_point: Annotated[ - list[FocalPointItem] | None, - Field( - description="Normalized `[x, y]` coordinates (0–1 from top-left) of the image's visual anchor. Seller-side renderers crop toward the focal point when deriving renditions across breakpoints and aspect ratios; absent, cropping falls back to center-weighted defaults.", - max_length=2, - min_length=2, - ), - ] = None - format: Annotated[ - str | None, Field(description="Image file format (jpg, png, gif, webp, etc.)") - ] = None - alt_text: Annotated[str | None, Field(description="Alternative text for accessibility")] = None - provenance: Annotated[ - CoreProvenance | None, - Field( - description="Provenance metadata for this asset, overrides manifest-level provenance" - ), - ] = None - - -class Country(RootModel[str]): - root: Annotated[str, Field(pattern="^[A-Z]{2}$")] - - -class DataSubjectContestation(BaseModel): - """ - Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing. - """ - - model_config = ConfigDict( - extra="forbid", - ) - url: AnyUrl - email: EmailStr | None = None - languages: list[str] | None = None - - -class DataSubjectContestation1(BaseModel): - """ - Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing. - """ - - model_config = ConfigDict( - extra="forbid", - ) - url: AnyUrl | None = None - email: EmailStr - languages: list[str] | None = None - - -class Colors(BaseModel): - """ - Override brand colors (hex strings). - """ - - model_config = ConfigDict( - extra="allow", - ) - primary: Annotated[str | None, Field(pattern="^#[0-9a-fA-F]{6}$")] = None - secondary: Annotated[str | None, Field(pattern="^#[0-9a-fA-F]{6}$")] = None - accent: Annotated[str | None, Field(pattern="^#[0-9a-fA-F]{6}$")] = None - - -class BrandKitOverride(BaseModel): - """ - Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset. - - **Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload. - """ - - model_config = ConfigDict( - extra="allow", - ) - logo: Annotated[CoreAssetsImageAsset | None, Field(description="Override logo asset.")] = None - colors: Annotated[Colors | None, Field(description="Override brand colors (hex strings).")] = ( - None - ) - voice: Annotated[ - str | None, - Field( - description="Override brand-voice description for surface-composed text/audio output." - ), - ] = None - tagline: Annotated[str | None, Field(description="Override tagline.")] = None - - -class CoreBrandRef(BaseModel): - """ - Reference to a brand by domain and optional brand_id. The domain hosts /.well-known/brand.json or is registered in the brand registry. For single-brand domains, brand_id can be omitted. For house-of-brands domains, brand_id identifies the specific brand. For creative production, brand.json is the canonical source of master brand identity (logo, palette, fonts, voice, and visual guidelines), subject only to the supported per-call fields in brand_kit_override. Catalogs supply product or item payload; catalog item asset groups, including property- or franchise-level logos, are item identity and do not override these master brand fields. - """ - - model_config = ConfigDict( - extra="forbid", - ) - domain: Annotated[ - str, - Field( - description="Domain where /.well-known/brand.json is hosted, or the brand's operating domain", - pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", - ), - ] - brand_id: Annotated[ - CoreBrandId | None, - Field( - description="Brand identifier within the house portfolio. Optional for single-brand domains." - ), - ] = None - countries: Annotated[ - list[Country] | None, - Field( - description="Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.", - min_length=1, - ), - ] = None - industries: Annotated[ - list[str] | None, - Field( - description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." - ), - ] = None - data_subject_contestation: Annotated[ - DataSubjectContestation | DataSubjectContestation1 | None, - Field( - description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." - ), - ] = None - brand_kit_override: Annotated[ - BrandKitOverride | None, - Field( - description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." - ), - ] = None - - -class Scope(str, Enum): - """ - Whether this relying_party_id is shared across the entity's properties (entity → a within-entity unique-human graph) or scoped to a single property (property → per-property pseudonyms, unlinkable across the entity). - """ - - entity = "entity" - property = "property" - - -class CoreIdentityRelyingParty(BaseModel): - """ - A verified-identity relying party an entity or brand operates. Used for attestation provenance in TMP Identity Match (the buyer checks a forwarded attestation's relying_party_id against the owner's published list). Issuer-agnostic; World ID is the first issuer. - """ - - model_config = ConfigDict( - extra="forbid", - ) - issuer: Annotated[ - CoreBrandRef, - Field( - description='Identity issuer / attestation authority, referenced as a vendor BrandRef (e.g. {"domain": "world.org"}) — the same vendor-reference shape AdCP uses for measurement and signals vendors. The issuer\'s canonical domain is the anchor; it need not host a brand.json, but if it does, that is where its verifier metadata (scheme versions, verify endpoint, JWKs) lives. Issuer-agnostic: World ID, ISO 18013-5 mDL, and W3C-VC issuers all reference by domain. The relying party is namespaced by the issuer — identity is the tuple (issuer.domain, issuer.brand_id, relying_party_id), mirroring (vendor.domain, vendor.brand_id, metric_id).' - ), - ] - scheme: Annotated[ - str | None, Field(description='Proof scheme and version, e.g. "world_id_v4".') - ] = None - relying_party_id: Annotated[ - str, - Field( - description="The relying-party id registered with the issuer (and, for on-chain issuers like World ID, with the issuer's registry). One entity may operate many relying parties (scope=entity vs scope=property); this is the attestation's audience / linkability boundary, not an entity identifier." - ), - ] - scope: Annotated[ - Scope | None, - Field( - description="Whether this relying_party_id is shared across the entity's properties (entity → a within-entity unique-human graph) or scoped to a single property (property → per-property pseudonyms, unlinkable across the entity)." - ), - ] = None - - -class DelegationType(str, Enum): - """ - Optional operator-side assertion of the commercial relationship, for sellers.json-style cross-checking. When present it MUST equal the publisher's delegation_type; a mismatch fails the acknowledgement (state 'mismatch'). Omit to defer entirely to the publisher. - """ - - direct = "direct" - delegated = "delegated" - ad_network = "ad_network" - - -class Adcp(BaseModel): - """ - AdCP profile for an acknowledgement. - """ - - model_config = ConfigDict( - extra="forbid", - ) - delegation_type: Annotated[ - DelegationType | None, - Field( - description="Optional operator-side assertion of the commercial relationship, for sellers.json-style cross-checking. When present it MUST equal the publisher's delegation_type; a mismatch fails the acknowledgement (state 'mismatch'). Omit to defer entirely to the publisher." - ), - ] = None - - -class Protocol(str, Enum): - mcp = "mcp" - a2a = "a2a" - - -class Adcp1(BaseModel): - """ - AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry. - """ - - model_config = ConfigDict( - extra="forbid", - ) - available_uses: Annotated[list[EnumsRightUse] | None, Field(min_length=1)] = None - right_types: Annotated[list[EnumsRightType] | None, Field(min_length=1)] = None - onboarding: CoreBuyerOnboardingPosture | None = None - - -class Brand(RootModel[str]): - root: Annotated[str, Field(pattern="^([a-z0-9_]+|\\*)$")] - - -class Adcp2(BaseModel): - """ - AdCP profile for a grant. - """ - - model_config = ConfigDict( - extra="forbid", - ) - brands: Annotated[ - list[Brand] | None, - Field( - description="Brand IDs from this organization's brand.json that the grant covers. ['*'] or omitted means every brand the grantor owns.", - min_length=1, - ), - ] = None - - -class HttpsUrl(RootModel[AnyUrl]): - root: Annotated[ - AnyUrl, - Field( - description="An HTTPS URL. Every such URL in this document is untrusted input and, when fetched, a fetch target: the pattern is not an SSRF check. Verifiers MUST apply their own SSRF validation (no userinfo, no private or link-local addresses, no redirects) and enforce body-size and item-count limits." - ), - ] - - -class Adcp3(BaseModel): - """ - AdCP profile for the whole record. - """ - - model_config = ConfigDict( - extra="forbid", - ) - identity_relying_parties: Annotated[ - list[CoreIdentityRelyingParty] | None, - Field( - description="Verified-identity relying parties this organization operates (TMP Identity Match attestation provenance). Intended to move here from brand.json in a future release; brand.json is unchanged in 3.3. Experimental." - ), - ] = None - onboarding: CoreBuyerOnboardingPosture | None = None - - -class Domain(RootModel[str]): - model_config = ConfigDict( - regex_engine="python-re", - ) - root: Annotated[ - str, - Field( - description="A lowercase DNS host name (internationalized names in punycode).", - pattern="^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$", - ), - ] - - -class GrantSource1(str, Enum): - """ - Where the grantor publishes the grant being acknowledged. Tokens in the adcp namespace MUST be a grant source AdCP defines: 'adcp:adagents' (the grantor's /.well-known/adagents.json authorized_agents[]) or 'adcp:trust' (the grantor's /.well-known/trust.json grants[]). - """ - - adcp_adagents = "adcp:adagents" - adcp_trust = "adcp:trust" - - -class GrantedAgent(BaseModel): - """ - An agent a grant is limited to, matched by canonical origin. Grants never carry keys. - """ - - model_config = ConfigDict( - extra="forbid", - ) - url: HttpsUrl - - -class OpenProfiles(RootModel[dict[Annotated[str, Field(pattern=r"^[a-z0-9-]+$")], dict[str, Any]]]): - root: Annotated[ - dict[Annotated[str, Field(pattern=r"^[a-z0-9-]+$")], dict[str, Any]], - Field( - description="Per-protocol data keyed by namespace. Consumers MUST ignore namespaces they do not implement. Nothing in profiles is key material or authority for another namespace." - ), - ] - - -class Role1(str, Enum): - """ - Functional role of an agent. Tokens in the adcp namespace MUST be one of the AdCP agent types. - """ - - adcp_brand = "adcp:brand" - adcp_rights = "adcp:rights" - adcp_measurement = "adcp:measurement" - adcp_governance = "adcp:governance" - adcp_creative = "adcp:creative" - adcp_sales = "adcp:sales" - adcp_buying = "adcp:buying" - adcp_signals = "adcp:signals" - - -class Scope1(str, Enum): - """ - An activity a grant covers. Tokens in the adcp namespace MUST be one of the AdCP scopes (brand.json authorized_operator scopes plus 'adcp:brand_identity'). The bare token '*' covers every scope in every namespace. - """ - - adcp_all = "adcp:all" - adcp_media_buying = "adcp:media_buying" - adcp_creative_generation = "adcp:creative_generation" - adcp_rights_clearance = "adcp:rights_clearance" - adcp_governance = "adcp:governance" - adcp_measurement = "adcp:measurement" - adcp_agent_operations = "adcp:agent_operations" - adcp_brand_identity = "adcp:brand_identity" - - -class NamespacedToken(RootModel[str]): - root: Annotated[ - str, - Field( - description="A ':' token. Each namespace is defined by the protocol that owns it (e.g. 'adcp:' by AdCP). Consumers MUST ignore tokens in namespaces they do not implement.", - pattern="^[a-z0-9-]+:[a-z0-9_.-]+$", - ), - ] - - -class TrustV1Trust1(BaseModel): - """ - Pointer for hosted records (a managed network, a platform, AAO hosting). The organization is still the host that served this pointer. Verifiers follow at most one hop, with zero HTTP redirects, and MUST NOT follow a second pointer. Hosts serving records on an organization's behalf MUST only serve records the organization controls. - """ - - model_config = ConfigDict( - extra="forbid", - ) - field_schema: Annotated[str | None, Field(alias="$schema")] = None - authoritative_location: Annotated[ - HttpsUrl, Field(description="HTTPS URL of the authoritative trust.json for this host.") - ] - last_updated: AwareDatetime | None = None - - -class Profiles(BaseModel): - adcp: Annotated[Adcp | None, Field(description="AdCP profile for an acknowledgement.")] = None - - -class Profiles1(BaseModel): - adcp: Annotated[ - Adcp1 | None, - Field( - description="AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry." - ), - ] = None - - -class Profiles2(BaseModel): - adcp: Annotated[Adcp2 | None, Field(description="AdCP profile for a grant.")] = None - - -class RecordProfiles(BaseModel): - adcp: Annotated[Adcp3 | None, Field(description="AdCP profile for the whole record.")] = None - - -class GrantSource(RootModel[NamespacedToken | GrantSource1]): - root: Annotated[ - NamespacedToken | GrantSource1, - Field( - description="Where the grantor publishes the grant being acknowledged. Tokens in the adcp namespace MUST be a grant source AdCP defines: 'adcp:adagents' (the grantor's /.well-known/adagents.json authorized_agents[]) or 'adcp:trust' (the grantor's /.well-known/trust.json grants[])." - ), - ] - - -class RoleModel(RootModel[NamespacedToken | Role1]): - root: Annotated[ - NamespacedToken | Role1, - Field( - description="Functional role of an agent. Tokens in the adcp namespace MUST be one of the AdCP agent types." - ), - ] - - -class ScopeModel(RootModel[Literal["*"] | NamespacedToken | Scope1]): - root: Annotated[ - Literal["*"] | NamespacedToken | Scope1, - Field( - description="An activity a grant covers. Tokens in the adcp namespace MUST be one of the AdCP scopes (brand.json authorized_operator scopes plus 'adcp:brand_identity'). The bare token '*' covers every scope in every namespace." - ), - ] - - -class Acknowledgement(BaseModel): - """ - This organization's acceptance of a grant another organization published: 'we accept the grant `grantor` gave to `agent_url`, as published via `via`.' Terms are read ONLY from the grantor's document and are never restated here. Not needed when the grantor has the same eTLD+1 as the host serving this document. In the AdCP profile with via 'adcp:adagents' this is intended to supersede brand.json properties[] entries whose relationship is direct, delegated, or ad_network in a future release; brand.json is unchanged in 3.3. - """ - - model_config = ConfigDict( - extra="forbid", - ) - grantor: Annotated[Domain, Field(description="The organization that published the grant.")] - agent_url: Annotated[ - HttpsUrl, - Field( - description="The granted agent. MUST match an agents[] entry in this same document by canonical origin; an acknowledgement for an agent this organization does not run is invalid and MUST be ignored." - ), - ] - via: Annotated[ - GrantSource, - Field( - description="Where the grant is published. For 'adcp:adagents', verifiers resolve https://{grantor}/.well-known/adagents.json (following authoritative_location and managed-network rules) and require an authorized_agents[] entry whose canonical url equals agent_url and that reaches the grantor under the reachable-publisher rule. One acknowledgement covers every entry for that agent URL, whatever its authorization_type. The adagents.json comparison is by canonical URL, as that file defines (see docs/reference/url-canonicalization.mdx), not by origin." - ), - ] - profiles: Profiles | None = None - last_updated: AwareDatetime | None = None - - -class Agent(BaseModel): - """ - An agent this organization runs, identified by the canonical origin of `url`. One agent per origin. Its signing keys are published only in the key directory at that origin (`/.well-known/http-message-signatures-directory`, Web Bot Auth), never in this document. - """ - - model_config = ConfigDict( - extra="forbid", - ) - url: Annotated[ - HttpsUrl, - Field( - description="Agent endpoint URL, which may carry a path. Its host eTLD+1 MUST equal the eTLD+1 of the organization this record speaks for." - ), - ] - roles: Annotated[ - list[RoleModel], - Field(description="What this agent does, as namespaced roles.", min_length=1), - ] - protocols: Annotated[ - list[Protocol] | None, - Field(description="Transport protocols the endpoint speaks. Informational."), - ] = None - id: Annotated[ - str | None, - Field( - description="Operator-chosen identifier for logging and routing. Not an identity input." - ), - ] = None - description: str | None = None - countries: Annotated[ - list[Country] | None, - Field( - description="ISO 3166-1 alpha-2 country codes where this agent operates. Omit for global." - ), - ] = None - profiles: Profiles1 | None = None - last_updated: AwareDatetime | None = None - - -class Grant(BaseModel): - """ - Authority this organization delegates: 'the grantee (optionally only these agents) may act for us, for these scopes, in these countries, during this window.' Non-transitive: a grantee never extends a grant it received. It MAY make its own grant to one intermediary for scopes it holds, and effective authority is the intersection along that chain (brands, countries, scopes, earliest valid_until); the intermediary cannot grant further. In the AdCP profile this is the demand-side grant (#6033, DR-0025): relying parties evaluate it at sync_accounts from the brand's own record. When this document is fetched successfully, brand.json authorized_operators[] is not consulted for the same grantor. - """ - - model_config = ConfigDict( - extra="forbid", - ) - grantee: Annotated[ - Domain, - Field( - description="Domain of the organization receiving the grant. Its agents resolve to this domain through its own trust.json." - ), - ] - agents: Annotated[ - list[GrantedAgent] | None, - Field( - description="Optional narrowing to specific agents the grantee runs. When absent, any agent that resolves to the grantee acts under the grant.", - min_length=1, - ), - ] = None - scopes: Annotated[list[ScopeModel], Field(min_length=1)] - countries: Annotated[ - list[Country] | None, - Field( - description="ISO 3166-1 alpha-2 country codes where the grant applies. Omit for global." - ), - ] = None - valid_from: Annotated[ - AwareDatetime | None, Field(description="Consumers MUST ignore the grant before this time.") - ] = None - valid_until: Annotated[ - AwareDatetime | None, - Field( - description="Consumers MUST treat the grant as inactive at or after this time, on every check." - ), - ] = None - profiles: Profiles2 | None = None - last_updated: AwareDatetime | None = None - - -class TrustV1Trust2(BaseModel): - """ - An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md. - """ - - model_config = ConfigDict( - extra="forbid", - ) - field_schema: Annotated[str | None, Field(alias="$schema")] = None - version: str | None = None - last_updated: AwareDatetime | None = None - agents: Annotated[list[Agent], Field(description="Agents this organization runs.")] - grants: Annotated[ - list[Grant] | None, Field(description="Authority this organization delegates to others.") - ] = None - exclusive_grants: Annotated[ - bool | None, - Field( - description="When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - ), - ] = False - acknowledgements: Annotated[ - list[Acknowledgement] | None, - Field( - description="Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small." - ), - ] = None - acknowledgements_url: Annotated[ - HttpsUrl | None, - Field( - description="Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - ), - ] = None - profiles: RecordProfiles | None = None - ext: Annotated[ - dict[str, dict[str, Any]] | None, - Field( - description="Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority." - ), - ] = None - - -class TrustV1Trust3(BaseModel): - """ - An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md. - """ - - model_config = ConfigDict( - extra="forbid", - ) - field_schema: Annotated[str | None, Field(alias="$schema")] = None - version: str | None = None - last_updated: AwareDatetime | None = None - agents: Annotated[list[Agent] | None, Field(description="Agents this organization runs.")] = ( - None - ) - grants: Annotated[ - list[Grant], Field(description="Authority this organization delegates to others.") - ] - exclusive_grants: Annotated[ - bool | None, - Field( - description="When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - ), - ] = False - acknowledgements: Annotated[ - list[Acknowledgement] | None, - Field( - description="Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small." - ), - ] = None - acknowledgements_url: Annotated[ - HttpsUrl | None, - Field( - description="Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - ), - ] = None - profiles: RecordProfiles | None = None - ext: Annotated[ - dict[str, dict[str, Any]] | None, - Field( - description="Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority." - ), - ] = None - - -class TrustV1Trust4(BaseModel): - """ - An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md. - """ - - model_config = ConfigDict( - extra="forbid", - ) - field_schema: Annotated[str | None, Field(alias="$schema")] = None - version: str | None = None - last_updated: AwareDatetime | None = None - agents: Annotated[list[Agent] | None, Field(description="Agents this organization runs.")] = ( - None - ) - grants: Annotated[ - list[Grant] | None, Field(description="Authority this organization delegates to others.") - ] = None - exclusive_grants: Annotated[ - bool | None, - Field( - description="When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - ), - ] = False - acknowledgements: Annotated[ - list[Acknowledgement], - Field( - description="Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small." - ), - ] - acknowledgements_url: Annotated[ - HttpsUrl | None, - Field( - description="Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - ), - ] = None - profiles: RecordProfiles | None = None - ext: Annotated[ - dict[str, dict[str, Any]] | None, - Field( - description="Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority." - ), - ] = None - - -class TrustV1Trust5(BaseModel): - """ - An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md. - """ - - model_config = ConfigDict( - extra="forbid", - ) - field_schema: Annotated[str | None, Field(alias="$schema")] = None - version: str | None = None - last_updated: AwareDatetime | None = None - agents: Annotated[list[Agent] | None, Field(description="Agents this organization runs.")] = ( - None - ) - grants: Annotated[ - list[Grant] | None, Field(description="Authority this organization delegates to others.") - ] = None - exclusive_grants: Annotated[ - bool | None, - Field( - description="When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - ), - ] = False - acknowledgements: Annotated[ - list[Acknowledgement] | None, - Field( - description="Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small." - ), - ] = None - acknowledgements_url: Annotated[ - HttpsUrl, - Field( - description="Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - ), - ] - profiles: RecordProfiles | None = None - ext: Annotated[ - dict[str, dict[str, Any]] | None, - Field( - description="Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority." - ), - ] = None - - -class TrustV1Trust6(BaseModel): - """ - An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md. - """ - - model_config = ConfigDict( - extra="forbid", - ) - field_schema: Annotated[str | None, Field(alias="$schema")] = None - version: str | None = None - last_updated: AwareDatetime | None = None - agents: Annotated[list[Agent] | None, Field(description="Agents this organization runs.")] = ( - None - ) - grants: Annotated[ - list[Grant] | None, Field(description="Authority this organization delegates to others.") - ] = None - exclusive_grants: Annotated[ - bool | None, - Field( - description="When true, grants[] is exhaustive for every scope it names: relying parties MUST reject acts-for claims no active grant covers. When false or absent, an uncovered claim is at the relying party's discretion. A fetch failure is never equivalent to an absent grant; once a relying party has seen exclusive_grants: true for a host, it MUST fail closed on fetch failure." - ), - ] = False - acknowledgements: Annotated[ - list[Acknowledgement] | None, - Field( - description="Inline acknowledgements. Mutually exclusive with acknowledgements_url. Organizations acknowledging more than a few hundred grants SHOULD use acknowledgements_url so the key-resolution fetch stays small." - ), - ] = None - acknowledgements_url: Annotated[ - HttpsUrl | None, - Field( - description="Same-host HTTPS URL of a trust-acknowledgements.json sub-document (untrusted input; see https_url). Fetched only by verifiers checking a grant relationship, never on the signature-verification path, under its own size and cache profile." - ), - ] = None - profiles: RecordProfiles - ext: Annotated[ - dict[str, dict[str, Any]] | None, - Field( - description="Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority." - ), - ] = None - - -class TrustV1Trust( - RootModel[ - TrustV1Trust1 - | TrustV1Trust2 - | TrustV1Trust3 - | TrustV1Trust4 - | TrustV1Trust5 - | TrustV1Trust6 - ] -): - root: Annotated[ - TrustV1Trust1 - | TrustV1Trust2 - | TrustV1Trust3 - | TrustV1Trust4 - | TrustV1Trust5 - | TrustV1Trust6, - Field( - description="An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json). In AdCP 3.3 the experimental Web Bot Auth signing profile reads agents[] (origin and roles) when it evaluates a trust record derived from an agent's origin (docs/building/by-layer/L1/security.mdx, Web Bot Auth identities); grants[] are read when a relying party evaluates acts-for authority at sync_accounts (DR-0025). Outside those uses, third parties, crawlers and registries MUST NOT treat a published trust.json as authority. brand.json behavior is unchanged for the RFC 9421 signing profile. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md.", - title="trust.json", - ), - ] - - -class IdentityPreview( - RootModel[ - CoreAgentOnboarding | CoreBuyerOnboardingPosture | CorePrincipalRelationship | TrustV1Trust - ] -): - root: Annotated[ - CoreAgentOnboarding | CoreBuyerOnboardingPosture | CorePrincipalRelationship | TrustV1Trust, - Field(title="IdentityPreview"), - ] diff --git a/src/adcp/types/experimental_identity.py b/src/adcp/types/experimental_identity.py deleted file mode 100644 index b9df09398..000000000 --- a/src/adcp/types/experimental_identity.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Opt-in types for pinned AdCP 3.3 identity drafts, independent of the wire pin. - -Regenerate the private models with scripts/generate_identity_preview.py. -Validate trust records with adcp.signing.validate_trust_record: origin ownership -and one-agent-per-origin are behavioral constraints, beyond generated types. -""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel - -from adcp.types._identity_preview import ( - Agent as TrustAgent, -) -from adcp.types._identity_preview import ( - CoreAgentOnboarding as AgentOnboarding, -) -from adcp.types._identity_preview import ( - CoreAgentOnboardingRequirement as AgentOnboardingRequirement, -) -from adcp.types._identity_preview import ( - CoreAgentOnboardingTier as AgentOnboardingTier, -) -from adcp.types._identity_preview import ( - CoreBuyerOnboardingPosture as BuyerOnboardingPosture, -) -from adcp.types._identity_preview import ( - CorePrincipalRelationship as PrincipalRelationship, -) -from adcp.types._identity_preview import ( - EnumsAgentOnboardingMode as AgentOnboardingMode, -) -from adcp.types._identity_preview import ( - EnumsAgentOnboardingRequirementKind as AgentOnboardingRequirementKind, -) -from adcp.types._identity_preview import ( - EnumsBuyerOnboardingMode as BuyerOnboardingMode, -) -from adcp.types._identity_preview import ( - Grant as TrustGrant, -) -from adcp.types._identity_preview import ( - TrustV1Trust as TrustRecord, -) -from adcp.types.domains.core.principal_changed_webhook import ( - PrincipalChangedWebhook as _PrincipalChangedWebhook, -) -from adcp.types.domains.core.principal_changed_webhook import ( - Reason as _PrincipalChangedReason, -) -from adcp.types.domains.protocol.get_adcp_capabilities_response import ( - Account as _Account, -) -from adcp.types.domains.protocol.get_adcp_capabilities_response import ( - GetAdcpCapabilitiesResponse as _GetAdcpCapabilitiesResponse, -) -from adcp.types.domains.protocol.get_adcp_capabilities_response import ( - RequestSigning as _RequestSigning, -) -from adcp.types.domains.protocol.get_principal_response import ( - GetPrincipalResponse as _GetPrincipalResponse, -) -from adcp.types.domains.protocol.get_principal_response import ( - PrincipalCurrentResult as _PrincipalCurrentResult, -) -from adcp.types.domains.protocol.get_principal_response import ( - PrincipalReadFailedResult, -) -from adcp.types.domains.protocol.get_principal_response import ( - PrincipalRecognizedResult as _PrincipalRecognizedResult, -) -from adcp.types.domains.protocol.get_principal_response import ( - PrincipalUnconfiguredResult as _PrincipalUnconfiguredResult, -) - - -class AccountCapabilities(_Account): - agent_onboarding: AgentOnboarding | None = None - - -class RequestSigning(_RequestSigning): - """Draft accepted transport profiles; omission means the legacy profile only.""" - - profiles: list[str] | None = None - - -class GetAdcpCapabilitiesResponse(_GetAdcpCapabilitiesResponse): - # Pyright treats mutable Pydantic fields as invariant even for subclasses. - account: AccountCapabilities | None = ( # pyright: ignore[reportIncompatibleVariableOverride] - None - ) - request_signing: ( # pyright: ignore[reportIncompatibleVariableOverride] - RequestSigning | None - ) = None - - -class PrincipalUnconfiguredResult(_PrincipalUnconfiguredResult): - relationship: PrincipalRelationship | None = None - - -class PrincipalRecognizedResult(_PrincipalRecognizedResult): - relationship: PrincipalRelationship | None = None - - -class PrincipalCurrentResult(_PrincipalCurrentResult): - relationship: PrincipalRelationship | None = None - - -class GetPrincipalResponse(_GetPrincipalResponse): - result: ( # pyright: ignore[reportIncompatibleVariableOverride] - PrincipalUnconfiguredResult - | PrincipalRecognizedResult - | PrincipalCurrentResult - | PrincipalReadFailedResult - ) - - -class PrincipalChangedWebhook(_PrincipalChangedWebhook): - # Draft webhook reasons intentionally expand the stable response enum. - reason: _PrincipalChangedReason | Literal["relationship_changed", "relationship_expiring"] # type: ignore[assignment] - - -class GrantAbsentDetails(BaseModel): - scope: Literal["agent"] = "agent" - reason: Literal["grant_absent"] = "grant_absent" - - -__all__ = [ - "AccountCapabilities", - "AgentOnboarding", - "AgentOnboardingMode", - "AgentOnboardingRequirement", - "AgentOnboardingRequirementKind", - "AgentOnboardingTier", - "BuyerOnboardingMode", - "BuyerOnboardingPosture", - "GetAdcpCapabilitiesResponse", - "GetPrincipalResponse", - "GrantAbsentDetails", - "PrincipalChangedWebhook", - "PrincipalCurrentResult", - "PrincipalRecognizedResult", - "PrincipalRelationship", - "PrincipalUnconfiguredResult", - "RequestSigning", - "TrustAgent", - "TrustGrant", - "TrustRecord", -] diff --git a/src/adcp/webhook_sender.py b/src/adcp/webhook_sender.py index b0f0185b0..467c7be6a 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -786,7 +786,6 @@ async def send_prepared( prepared: PreparedWebhook, *, before_attempt: Callable[[], Awaitable[bool]] | None = None, - experimental_agent_identity: bool = False, ) -> WebhookDeliveryResult: """Sign and post a previously prepared immutable webhook request. @@ -812,13 +811,6 @@ async def send_prepared( raise ValueError( "prepared webhook body idempotency_key does not match its immutable binding" ) - from adcp._notification_envelope import validate_notification_body - - validate_notification_body( - prepared.body, - idempotency_key=prepared.idempotency_key, - experimental_agent_identity=experimental_agent_identity, - ) return await self._send_bytes( url=prepared.url, body=prepared.body, @@ -835,7 +827,6 @@ def _prepare_notification( payload: Mapping[str, Any] | BaseModel, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, - experimental_agent_identity: bool = False, ) -> PreparedWebhook: value = ( payload.model_dump(mode="json", exclude_none=True) @@ -848,11 +839,7 @@ def _prepare_notification( value.setdefault("fired_at", datetime.now(timezone.utc).isoformat()) key = idempotency_key or value.get("idempotency_key") or generate_webhook_idempotency_key() return WebhookSender.prepare_raw( - url=url, - payload=value, - idempotency_key=key, - extra_headers=extra_headers, - experimental_agent_identity=experimental_agent_identity, + url=url, payload=value, idempotency_key=key, extra_headers=extra_headers ) @staticmethod @@ -979,7 +966,6 @@ def prepare_principal_changed( payload: Mapping[str, Any] | BaseModel, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, - experimental_agent_identity: bool = False, ) -> PreparedWebhook: """Prepare a schema-validated ``principal.changed`` notification. @@ -992,7 +978,6 @@ def prepare_principal_changed( payload=payload, idempotency_key=idempotency_key, extra_headers=extra_headers, - experimental_agent_identity=experimental_agent_identity, ) async def send_principal_changed( @@ -1002,7 +987,6 @@ async def send_principal_changed( payload: Mapping[str, Any] | BaseModel, idempotency_key: str | None = None, extra_headers: Mapping[str, str] | None = None, - experimental_agent_identity: bool = False, ) -> WebhookDeliveryResult: """Prepare and send a ``principal.changed`` notification in one attempt.""" return await self.send_prepared( @@ -1011,9 +995,7 @@ async def send_principal_changed( payload=payload, idempotency_key=idempotency_key, extra_headers=extra_headers, - experimental_agent_identity=experimental_agent_identity, - ), - experimental_agent_identity=experimental_agent_identity, + ) ) @staticmethod @@ -1619,7 +1601,6 @@ async def send_raw( idempotency_key: str, payload: dict[str, Any], extra_headers: Mapping[str, str] | None = None, - experimental_agent_identity: bool = False, ) -> WebhookDeliveryResult: """Prepare and send a notification in one attempt.""" return await self.send_prepared( @@ -1628,9 +1609,7 @@ async def send_raw( idempotency_key=idempotency_key, payload=payload, extra_headers=extra_headers, - experimental_agent_identity=experimental_agent_identity, - ), - experimental_agent_identity=experimental_agent_identity, + ) ) @staticmethod @@ -1640,7 +1619,6 @@ def prepare_raw( idempotency_key: str | None = None, payload: Mapping[str, Any], extra_headers: Mapping[str, str] | None = None, - experimental_agent_identity: bool = False, ) -> PreparedWebhook: """Serialize and validate a raw notification without network I/O. @@ -1665,7 +1643,7 @@ def prepare_raw( ) from adcp._notification_envelope import validate_notification_body - validate_notification_body(body, experimental_agent_identity=experimental_agent_identity) + validate_notification_body(body) return PreparedWebhook( url=url, body=body, @@ -1673,9 +1651,7 @@ def prepare_raw( extra_headers=dict(extra_headers) if extra_headers else {}, ) - async def resend( - self, result: WebhookDeliveryResult, *, experimental_agent_identity: bool = False - ) -> WebhookDeliveryResult: + async def resend(self, result: WebhookDeliveryResult) -> WebhookDeliveryResult: """Replay an earlier delivery under a fresh signature. The bytes are identical (same ``idempotency_key``, same payload @@ -1689,15 +1665,6 @@ async def resend( "cannot resend: result has no captured sent_body (likely constructed " "externally). Call a send_* method on this sender first." ) - from adcp._notification_envelope import validate_notification_body - - payload = json.loads(result.sent_body) - if isinstance(payload, dict) and payload.get("notification_type") == "principal.changed": - validate_notification_body( - result.sent_body, - idempotency_key=result.idempotency_key, - experimental_agent_identity=experimental_agent_identity, - ) return await self._send_bytes( url=result.url, body=result.sent_body, diff --git a/tests/conformance/decisioning/test_pg_notification_outbox.py b/tests/conformance/decisioning/test_pg_notification_outbox.py index 1252d8588..a575f4eac 100644 --- a/tests/conformance/decisioning/test_pg_notification_outbox.py +++ b/tests/conformance/decisioning/test_pg_notification_outbox.py @@ -115,12 +115,7 @@ def sender_mock() -> MagicMock: sender.signs_with_rfc9421 = True sender._auth.alg = "ed25519" - async def send( - value: PreparedWebhook, - *, - before_attempt: Any = None, - experimental_agent_identity: bool = False, - ) -> WebhookDeliveryResult: + async def send(value: PreparedWebhook, *, before_attempt: Any = None) -> WebhookDeliveryResult: assert before_attempt is None or await before_attempt() return WebhookDeliveryResult( status_code=200, @@ -180,40 +175,6 @@ async def enqueue( ) -async def test_experimental_principal_notification_is_opt_in_through_durable_replay( - stack: tuple[Any, PgNotificationOutbox, MagicMock], -) -> None: - pool, stable_outbox, sender = stack - value = WebhookSender.prepare_principal_changed( - url=URL, - idempotency_key=KEY, - payload={ - "notification_id": "transition_1", - "subscriber_id": "buyer", - "fired_at": AT, - "agent_url": "https://seller.example", - "changed_at": AT, - "reason": "relationship_changed", - }, - experimental_agent_identity=True, - ) - with pytest.raises(ValueError, match="schema validation"): - await enqueue(pool, stable_outbox, value) - preview_outbox = PgNotificationOutbox( - pool=pool, - sender=sender, - encryption_key=b"x" * 32, - delivery_retry_horizon_seconds=86400, - table=stable_outbox._table, - experimental_agent_identity=True, - ) - await enqueue(pool, preview_outbox, value) - assert await preview_outbox.process_one() - sent = sender.send_prepared.await_args - assert sent.kwargs["experimental_agent_identity"] is True - assert sent.args[0].body == value.body - - async def row(pool: Any, outbox: PgNotificationOutbox, row_id: int) -> dict[str, Any]: async with pool.connection() as conn: from psycopg.rows import dict_row diff --git a/tests/conformance/signing/test_structured_field_duplicates.py b/tests/conformance/signing/test_structured_field_duplicates.py deleted file mode 100644 index 0661ec1fe..000000000 --- a/tests/conformance/signing/test_structured_field_duplicates.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Structured-field ambiguity fails before discovery, replay, or dispatch.""" - -from __future__ import annotations - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from adcp.server import RequestSignatureVerification -from adcp.server.signed_requests import SignedRequestVerificationMiddleware -from adcp.signing import ( - REQUEST_SIGNATURE_HEADER_MALFORMED, - InMemoryReplayStore, - SignatureVerificationError, - VerifierCapability, - VerifyOptions, - sign_request, - sign_wba_request, - verify_request_signature, - verify_wba_request, -) -from adcp.signing._structured_fields import reject_dictionary_duplicates -from adcp.signing.canonical import parse_signature_input_header -from adcp.signing.crypto import extract_signature_bytes -from adcp.signing.wba import WbaVerificationError, _dictionary, uses_web_bot_auth - - -@pytest.mark.parametrize( - "field", - [ - 'sig1=("@method");created=1;created=2', - 'sig1=("a";key="one";key="two")', - "sig1=(1;key=one;key=two 2)", - "sig1=(:YWJj:;p=1;p=2)", - 'sig1=("a");flag;flag=?0', - 'sig1=:YWJj:;p="one";p="two"', - 'sig1="https://buyer.com";type=directory;type=directory', - 'sig1="a", sig1="b"', - "sig1;p=1, sig1;p=2", - 'sig1=("a");nonce="comma, ; created=1";nonce="second"', - 'sig1=("a";p="escaped \\" quote";p="second")', - 'sig1=("a");p=:YWJj:;p=:YWJj:', - 'sig1=("a");p=%"comma, (;)";p=%"second"', - ], -) -def test_duplicate_dictionary_or_parameter_evidence_is_preserved(field): - with pytest.raises(ValueError, match="duplicate"): - reject_dictionary_duplicates(field) - with pytest.raises(WbaVerificationError, match="duplicate") as rejected: - _dictionary(field) - assert rejected.value.status == 400 - assert not rejected.value.headers - - -@pytest.mark.parametrize( - "field", - [ - "sig1=(1;p=one 2;p=two);p=outer", - "sig1=(:YWJj:;p=1 :YWJj:;p=2);p=3", - 'sig1=("a";key="one" "b";key="two");key="outer"', - 'sig1=("a";p=1 "a";p=1)', - 'sig1=("a");nonce="comma, ; created=1";created=2', - 'sig1=("a");nonce="escaped \\" quote, (;)";created=2', - 'sig1=("a");nonce="trailing \\\\";created=2', - "sig1;flag=1,\t sig2;flag=2", - 'sig1=("a";p=1),\t sig2=("a";p=2)', - 'sig1=(%"comma, (;)";p=1 %"a%22b";p=2);p=outer', - "sig1=(@123;p=?1 @456;p=?0);p=outer", - ], -) -def test_parameter_scopes_and_quoted_delimiters_remain_legal(field): - reject_dictionary_duplicates(field) - assert _dictionary(field) - - -def test_existing_public_readers_reject_duplicate_labels_and_parameters(): - for field in ( - 'sig1=("@method");created=1;created=2', - 'sig1=("@method"), sig1=("@path")', - ): - with pytest.raises(ValueError, match="duplicate"): - parse_signature_input_header(field) - with pytest.raises(ValueError, match="duplicate"): - extract_signature_bytes("sig1=:YWJj:, sig1=:YWJj:") - # Keep explicitly enabled legacy Base64URL parsing intact. - assert extract_signature_bytes("sig1=:-_8:") == b"\xfb\xff" - - -@pytest.mark.parametrize( - "nonce", - ["web-bot-auth", 'prefix;tag=\\"web-bot-auth\\"', "prefix, web-bot-auth"], -) -def test_legacy_duplicate_with_quoted_marker_text_keeps_legacy_malformed_code(nonce): - field = f'sig1=("@method");created=1;created=2;nonce="{nonce}";tag="adcp/request-signing/v1"' - headers = {"Signature-Input": field, "Signature": "sig1=:YWJj:"} - assert not uses_web_bot_auth(headers) - with pytest.raises(SignatureVerificationError) as rejected: - verify_request_signature( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - options=VerifyOptions( - now=1000, - jwks_resolver=lambda key: pytest.fail("No discovery"), - capability=VerifierCapability(covers_content_digest="required"), - operation="create_media_buy", - ), - ) - assert rejected.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED - - -@pytest.mark.parametrize( - "field", - [ - 'sig1=("@method");tag="web-bot-auth";tag="adcp/request-signing/v1"', - 'sig1=("@method");created=1;created=2;tag="web-bot-auth"', - 'sig1=("@method");tag="web-bot-auth", sig1=("@path");tag="legacy"', - 'sig1=("@method");tag="web-bot-auth";nonce="unterminated', - ], -) -def test_explicit_wba_marker_cannot_be_overwritten_or_downgraded(field): - assert uses_web_bot_auth({"Signature-Input": field}) - with pytest.raises(WbaVerificationError) as rejected: - _dictionary(field) - assert rejected.value.status == 400 - - -def test_component_tag_parameter_is_not_a_signature_profile_marker(): - assert not uses_web_bot_auth( - {"Signature-Input": 'sig1=("x";tag="web-bot-auth");tag="adcp/request-signing/v1"'} - ) - - -class CountingReplayStore(InMemoryReplayStore): - def __init__(self): - super().__init__() - self.claims = [] - - def claim(self, keyid, nonce, ttl_seconds): - self.claims.append((keyid, nonce)) - return super().claim(keyid, nonce, ttl_seconds) - - -ATTACKS = [ - ("Signature-Input", "label"), - ("Signature-Input", "parameter"), - ("Signature-Input", "unused_member"), - ("Signature", "label"), - ("Signature", "parameter"), - ("Content-Digest", "parameter"), -] - - -def attack(headers, field, kind): - value = headers[field] - if kind == "label": - return {**headers, field: value + ", " + value} - if kind == "unused_member": - return {**headers, field: value + ', ignored=("a");created=1;created=2'} - suffix = { - "Signature-Input": ";created=1000", - "Signature-Agent": ";type=directory;type=directory", - }.get(field, ";p=1;p=2") - return {**headers, field: value + suffix} - - -@pytest.mark.parametrize("field,kind", ATTACKS) -def test_legacy_ambiguity_is_malformed_before_resolver_or_nonce(field, kind): - calls, replay = [], CountingReplayStore() - key = Ed25519PrivateKey.generate() - headers = {"Content-Type": "application/json"} - headers.update( - sign_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - private_key=key, - key_id="kid", - alg="ed25519", - created=1000, - signing_profile_version="3.2", - ).as_dict() - ) - - def resolve(keyid): - calls.append(keyid) - pytest.fail("Malformed fields must not reach discovery") - - with pytest.raises(SignatureVerificationError) as rejected: - verify_request_signature( - method="POST", - url="https://seller.com/mcp", - headers=attack(headers, field, kind), - body=b"{}", - options=VerifyOptions( - now=1000, - jwks_resolver=resolve, - capability=VerifierCapability(covers_content_digest="required"), - operation="create_media_buy", - replay_store=replay, - signing_profile_version="3.2", - ), - ) - assert rejected.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED - assert not calls and not replay.claims - - -@pytest.mark.parametrize( - "field,kind", ATTACKS + [("Signature-Agent", "label"), ("Signature-Agent", "parameter")] -) -async def test_wba_ambiguity_is_400_before_discovery_revocation_or_nonce(field, kind): - calls, replay = [], CountingReplayStore() - key = Ed25519PrivateKey.generate() - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - private_key=key, - agent_origin="https://buyer.com", - created=1000, - ) - ) - - async def resolve(keyid, *, signer_origin): - calls.append((keyid, signer_origin)) - pytest.fail("Malformed fields must not reach discovery") - - async def revoked(origin, keyid): - calls.append((origin, keyid)) - pytest.fail("Malformed fields must not reach revocation") - - with pytest.raises(WbaVerificationError) as rejected: - await verify_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=attack(headers, field, kind), - body=b"{}", - signer_keys=resolve, - replay_store=replay, - revocation_checker=revoked, - now=1000, - ) - assert rejected.value.status == 400 - assert not calls and not replay.claims - - -@pytest.mark.parametrize("wba", [False, True]) -async def test_framework_rejects_ambiguity_before_business_dispatch(wba): - calls, dispatched, replay = [], [], CountingReplayStore() - key = Ed25519PrivateKey.generate() - headers = {"Content-Type": "application/json"} - body = ( - b'{"jsonrpc":"2.0","id":1,"method":"tools/call",' - b'"params":{"name":"get_principal","arguments":{}}}' - ) - if wba: - headers.update( - sign_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=body, - private_key=key, - agent_origin="https://buyer.com", - created=1000, - ) - ) - else: - headers.update( - sign_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=body, - private_key=key, - key_id="kid", - alg="ed25519", - created=1000, - signing_profile_version="3.2", - ).as_dict() - ) - - async def resolve(keyid, **kwargs): - calls.append(keyid) - pytest.fail("Malformed headers must not discover a key") - - async def revoked(origin, keyid): - calls.append(origin) - pytest.fail("Malformed headers must not check revocation") - - async def dispatch(scope, receive, send): - dispatched.append(True) - pytest.fail("Malformed headers must not dispatch business logic") - - app = SignedRequestVerificationMiddleware( - dispatch, - RequestSignatureVerification( - signer_keys=resolve, - enable_web_bot_auth=True, - replay_store=replay, - wba_revocation_checker=revoked, - signing_profile_version="3.2", - ), - transport="mcp", - ) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app), base_url="https://seller.com" - ) as client: - response = await client.post( - "/mcp", content=body, headers=attack(headers, "Signature-Input", "parameter") - ) - assert response.status_code == (400 if wba else 401) - if not wba: - assert response.json()["error"] == REQUEST_SIGNATURE_HEADER_MALFORMED - assert not calls and not dispatched and not replay.claims diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py deleted file mode 100644 index 24ca7dee0..000000000 --- a/tests/conformance/signing/test_wba_profile.py +++ /dev/null @@ -1,720 +0,0 @@ -"""Draft WBA vectors and the security boundaries of the experimental profile.""" - -from __future__ import annotations - -import json -from dataclasses import replace -from pathlib import Path - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric import ed25519 -from starlette.requests import Request -from starlette.responses import JSONResponse - -from adcp.decisioning.types import AdcpError -from adcp.server.signed_requests import ( - RequestSignatureVerification, - SignedRequestVerificationMiddleware, - apply_verified_signer, - check_wba_authorization, -) -from adcp.server.translate import build_mcp_error_result -from adcp.signing import ( - DirectoryResponse, - DiscoveredSignerKeys, - InMemoryReplayStore, - OriginBindings, - SigningConfig, - StaticSignerKeys, - WbaVerificationError, - canonical_origin, - public_directory_key, - sign_key_directory, - sign_request, - sign_wba_request, - verify_directory_response, - verify_wba_request, -) -from adcp.signing.crypto import private_key_from_jwk -from adcp.signing.signer_keys import ResolvedSignerKey - -ROOT = Path(__file__).parents[1] / "vectors" / "wba-profile" -KEYS = {key["kid"]: key for key in json.loads((ROOT / "keys.json").read_text())["keys"]} -VECTORS = sorted((ROOT / "positive").glob("*.json")) + sorted((ROOT / "negative").glob("*.json")) - - -async def _not_revoked(origin, keyid): - return False - - -def _mcp_adcp_error(response): - payload = response.json() - # The bare ASGI fixture serializes the installed MCP model directly; - # supported MCP versions expose this field in camel or snake case. - content = ( - payload["structuredContent"] - if "structuredContent" in payload - else payload["structured_content"] - ) - return content["adcp_error"] - - -async def test_standalone_verification_requires_origin_revocation_checker(): - with pytest.raises(ValueError, match="origin revocation checker"): - await verify_wba_request( - method="POST", - url="https://seller.com/mcp", - headers={}, - body=b"", - signer_keys=StaticSignerKeys({}), - replay_store=InMemoryReplayStore(), - revocation_checker=None, - ) - - -def _trust_records(origin): - from adcp.signing import TrustRecordEvaluator, TrustResponse - - async def fetch(url, *, allow_private=False): - return TrustResponse( - json.dumps({"agents": [{"url": origin, "roles": ["adcp:buying"]}]}).encode() - ) - - return TrustRecordEvaluator(fetcher=fetch) - - -@pytest.mark.parametrize("path", VECTORS, ids=lambda p: f"{p.parent.name}/{p.name}") -async def test_draft_vectors(path): - vector = json.loads(path.read_text()) - request = vector["request"] - if "response" in vector: - response = vector["response"] - result = verify_directory_response( - request["url"], - DirectoryResponse(response["body"].encode(), response["headers"]), - now=vector["reference_now"], - ) - assert set(result) <= set(KEYS) - return - - async def resolve(keyid, *, signer_origin=None): - ids = vector["directories"].get(signer_origin, {}).get("keys_ref", []) - public = {k: v for k, v in KEYS[keyid].items() if not k.startswith("_")} - return ResolvedSignerKey(signer_origin, public) if keyid in ids else None - - replay = InMemoryReplayStore() - for entry in vector.get("test_harness_state", {}).get("replay_cache_entries", []): - replay.claim(entry["identity"], entry["nonce"], entry["ttl_seconds"]) - - async def verify(): - return await verify_wba_request( - method=request["method"], - url=request["url"], - headers=request["headers"], - body=request["body"].encode(), - signer_keys=resolve, - replay_store=replay, - revocation_checker=_not_revoked, - now=vector["reference_now"], - ) - - expected = vector["expected_outcome"] - if expected["success"]: - signers = await verify() - assert {s.label: s.agent_url for s in signers} == expected["identities"] - else: - with pytest.raises(WbaVerificationError) as caught: - await verify() - assert caught.value.status == expected["status"] - if expected["status"] == 403: - assert "Accept-Signature" in caught.value.headers - - -def test_signer_reproduces_independent_vector(): - vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) - request = vector["request"] - key = next(iter(KEYS.values())) - result = sign_wba_request( - method=request["method"], - url=request["url"], - headers={"Content-Type": "application/json"}, - body=request["body"].encode(), - private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), - agent_origin=key["identity"], - created=1790841600, - nonce="Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA", - ) - assert all(request["headers"][name] == value for name, value in result.items()) - - -@pytest.mark.parametrize( - "url,expected", - [ - ("HTTPS://Buyer.Example.COM.:443/mcp/?q=1", "https://buyer.example.com"), - ("https://bücher.de:8443/a", "https://xn--bcher-kva.de:8443"), - ("https://[2001:db8::1]:443/mcp", "https://[2001:db8::1]"), - ], -) -def test_origin_normalization(url, expected): - assert canonical_origin(url) == expected - - -@pytest.mark.parametrize( - "url", ["/mcp", "https://", "ftp://example.com", "https://[fe80::1%eth0]/"] -) -def test_invalid_origin(url): - with pytest.raises(ValueError): - canonical_origin(url) - - -async def test_directory_discovery_binds_origin_and_thumbprint_and_caches(): - key = ed25519.Ed25519PrivateKey.generate() - now = [1000.0] - calls = [] - responses = { - origin: sign_key_directory(agent_origin=origin, private_keys=[key], created=1000) - for origin in ("https://buyer.example.com", "https://other.example.com") - } - - async def fetch(url, *, allow_private=False): - calls.append(url) - return responses[canonical_origin(url)] - - resolver = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now[0]) - kid = public_directory_key(key)["kid"] - first = await resolver(kid, signer_origin="https://buyer.example.com") - second = await resolver(kid, signer_origin="https://other.example.com") - assert first.agent_url != second.agent_url - assert await resolver("unknown", signer_origin=first.agent_url) is None - assert await resolver(kid) is None - assert len(calls) == 2 - assert await resolver(kid, signer_origin=first.agent_url) == first - responses[first.agent_url] = sign_key_directory( - agent_origin=first.agent_url, - private_keys=[ed25519.Ed25519PrivateKey.generate()], - created=1301, - ) - now[0] = 1301 - assert await resolver(kid, signer_origin=first.agent_url) is None - assert len(calls) == 3 - - -async def test_directory_outage_cools_down(): - calls = [] - - async def fetch(url, *, allow_private=False): - calls.append(url) - raise OSError("offline") - - resolver = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1000) - for _ in range(2): - with pytest.raises(WbaVerificationError): - await resolver("kid", signer_origin="https://buyer.example.com") - assert len(calls) == 1 - - -@pytest.mark.parametrize("tamper", ["body", "authority", "future", "unsigned_key", "kid"]) -def test_signed_directory_rejects_tampering(tamper): - origin = "https://buyer.example.com" - response = sign_key_directory( - agent_origin=origin, private_keys=[ed25519.Ed25519PrivateKey.generate()], created=1000 - ) - now = 1001 - if tamper == "body": - response = replace(response, body=response.body + b" ") - elif tamper == "authority": - origin = "https://attacker.example.com" - elif tamper == "future": - now = 999 - else: - data = json.loads(response.body) - if tamper == "kid": - data["keys"][0]["kid"] = "x" * 43 - else: - data["keys"].append(public_directory_key(ed25519.Ed25519PrivateKey.generate())) - response = replace(response, body=json.dumps(data).encode()) - with pytest.raises(WbaVerificationError): - verify_directory_response(origin, response, now=now) - - -async def test_replay_is_per_origin_across_key_rotation(): - origin = "https://buyer.example.com" - keys = [ed25519.Ed25519PrivateKey.generate() for _ in range(2)] - jwks = {public_directory_key(key)["kid"]: public_directory_key(key) for key in keys} - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(origin, jwks[keyid]) - - replay = InMemoryReplayStore() - for index, key in enumerate(keys): - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=b"{}", - private_key=key, - agent_origin=origin, - created=1000, - nonce="a" * 43, - ) - ) - if index == 0: - assert await verify_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=b"{}", - signer_keys=resolve, - replay_store=replay, - revocation_checker=_not_revoked, - now=1001, - ) - else: - with pytest.raises(WbaVerificationError, match="replayed"): - await verify_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=b"{}", - signer_keys=resolve, - replay_store=replay, - revocation_checker=_not_revoked, - now=1001, - ) - - -async def test_framework_requires_explicit_binding_before_dispatch(): - origin = "https://buyer.example.com" - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - legacy_jwk = {**jwk, "adcp_use": "request-signing", "key_ops": ["verify"]} - bindings = OriginBindings() - dispatched = [] - - async def app(scope, receive, send): - context = apply_verified_signer(None, Request(scope)) - try: - check_wba_authorization(context) - except AdcpError as exc: - await JSONResponse(build_mcp_error_result(exc).model_dump(mode="json", by_alias=True))( - scope, receive, send - ) - return - dispatched.append( - scope["adcp.verified_signer"] if "adcp.verified_signer" in scope else scope - ) - await JSONResponse({"ok": True})(scope, receive, send) - - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({origin: {"keys": [legacy_jwk]}}), - enable_web_bot_auth=True, - origin_bindings=bindings, - wba_revocation_checker=_not_revoked, - trust_records=_trust_records(origin), - signing_profile_version="3.2", - ) - app = SignedRequestVerificationMiddleware( - app, config, transport="mcp", defer_authorization_errors=True - ) - body = ( - b'{"jsonrpc":"2.0","id":1,"method":"tools/call",' - b'"params":{"name":"sync_accounts","arguments":{}}}' - ) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app), base_url="https://seller.example.com" - ) as client: - - async def post(): - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=body, - private_key=private, - agent_origin=origin, - ) - ) - return await client.post("/mcp", content=body, headers=headers) - - denied = await post() - assert denied.status_code == 200, denied.text - assert _mcp_adcp_error(denied)["code"] == "PERMISSION_DENIED" - assert "Accept-Signature" not in denied.headers - assert not dispatched - bindings.bind(origin, "principal-1") - assert (await post()).status_code == 200 - assert len(dispatched) == 1 - legacy = sign_request( - method="POST", - url="https://seller.example.com/mcp", - headers={"Content-Type": "application/json"}, - body=body, - private_key=private, - key_id=jwk["kid"], - alg="ed25519", - signing_profile_version="3.2", - ) - response = await client.post( - "/mcp", content=body, headers={"Content-Type": "application/json", **legacy.as_dict()} - ) - assert response.status_code == 200, response.text - assert _mcp_adcp_error(response)["code"] == "PERMISSION_DENIED" - assert "Accept-Signature" not in response.headers - assert len(dispatched) == 1 - - -def test_binding_migration_requires_approval_and_never_rebinds(): - bindings = OriginBindings() - with pytest.raises(ValueError, match="approval"): - bindings.bind("https://buyer.example.com", "principal-1", existing_rfc9421_principal=True) - bindings.bind( - "https://buyer.example.com", - "principal-1", - existing_rfc9421_principal=True, - migration_approved=True, - ) - with pytest.raises(ValueError, match="rebound"): - bindings.bind("https://buyer.example.com", "principal-2") - - -def test_signing_config_requires_thumbprint_and_emits_opt_in_header(): - private = ed25519.Ed25519PrivateKey.generate() - with pytest.raises(ValueError, match="thumbprint"): - SigningConfig( - private_key=private, key_id="legacy-kid", agent_origin="https://buyer.example.com" - ) - config = SigningConfig( - private_key=private, - key_id=public_directory_key(private)["kid"], - agent_origin="https://Buyer.example.com/mcp", - ) - result = sign_request( - method="POST", - url="https://seller.example.com/mcp", - headers={"Content-Type": "application/json"}, - body=b"{}", - private_key=private, - key_id=config.key_id, - alg=config.alg, - signing_profile_version="3.1", - agent_origin=config.agent_origin, - ) - assert result.signature_agent == 'sig1="https://buyer.example.com"' - assert 'tag="web-bot-auth"' in result.signature_input - - -@pytest.mark.parametrize( - "field", - [ - 'sig1=("@method");created=1;created=2', - 'sig1=("signature-agent";key="sig1";key="other");created=1', - 'sig1="https://buyer.com";type="directory";type="other"', - ], -) -def test_duplicate_parameters_are_rejected_before_parsing(field): - from adcp.signing.wba import _dictionary - - with pytest.raises(WbaVerificationError, match="duplicate"): - _dictionary(field) - - -async def test_signer_overwrites_stale_digest_case_insensitively(): - key = ed25519.Ed25519PrivateKey.generate() - headers = {"content-type": "application/json", "content-digest": "stale"} - signed = sign_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - private_key=key, - agent_origin="https://buyer.com", - created=1000, - ) - merged = {**headers, **{k.lower(): v for k, v in signed.items()}} - jwk = public_directory_key(key) - signer = await verify_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=merged, - body=b"{}", - signer_keys=StaticSignerKeys({"https://buyer.com": {"keys": [jwk]}}), - replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, - now=1001, - ) - assert signer[0].body_authenticated - - -async def test_discovery_coalesces_per_origin_without_blocking_other_signers(): - import asyncio - - key = ed25519.Ed25519PrivateKey.generate() - kid = public_directory_key(key)["kid"] - started, release = asyncio.Event(), asyncio.Event() - calls = [] - - async def fetch(url, *, allow_private=False): - origin = canonical_origin(url) - calls.append(origin) - if origin == "https://slow.com": - started.set() - await release.wait() - return sign_key_directory(agent_origin=origin, private_keys=[key], created=1000) - - resolver = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1000) - first = asyncio.create_task(resolver(kid, signer_origin="https://slow.com")) - await started.wait() - second = asyncio.create_task(resolver(kid, signer_origin="https://slow.com")) - try: - fast = await asyncio.wait_for(resolver(kid, signer_origin="https://fast.com"), timeout=1) - assert fast.agent_url == "https://fast.com" - finally: - release.set() - assert await first == await second - assert calls.count("https://slow.com") == 1 - - -async def test_wba_discovery_never_falls_back_to_legacy_keys(): - key = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(key) - legacy = StaticSignerKeys({"https://legacy.com/mcp": {"keys": [jwk]}}) - - async def fetch(url, *, allow_private=False): - raise OSError("offline") - - resolver = DiscoveredSignerKeys(fetcher=fetch, legacy_resolver=legacy) - assert (await resolver(jwk["kid"])).agent_url == "https://legacy.com/mcp" - with pytest.raises(WbaVerificationError): - await resolver(jwk["kid"], signer_origin="https://legacy.com") - - -async def test_p256_wba_derives_optional_algorithm_and_signs_multikey_directory(): - from cryptography.hazmat.primitives.asymmetric import ec - - from adcp.signing.crypto import format_signature_header, sign_signature_base - from adcp.signing.wba import _base, _dictionary - - ed_key, ec_key = ed25519.Ed25519PrivateKey.generate(), ec.generate_private_key(ec.SECP256R1()) - response = sign_key_directory( - agent_origin="https://buyer.com", private_keys=[ed_key, ec_key], created=1000 - ) - keys = verify_directory_response("https://buyer.com", response, now=1001) - assert len(keys) == 2 - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - private_key=ec_key, - agent_origin="https://buyer.com", - created=1000, - ) - ) - inputs = _dictionary(headers["Signature-Input"]) - member = inputs["sig1"] - del member.params["alg"] - headers["Signature-Input"] = str(inputs) - signature = sign_signature_base( - alg="ecdsa-p256-sha256", - private_key=ec_key, - signature_base=_base("POST", "https://seller.com/mcp", headers, member), - ) - headers["Signature"] = format_signature_header( - signature, label="sig1", use_legacy_base64url=False - ) - result = await verify_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - signer_keys=StaticSignerKeys({"https://buyer.com": {"keys": list(keys.values())}}), - replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, - now=1001, - ) - assert result[0].alg == "ecdsa-p256-sha256" - - -@pytest.mark.parametrize( - "headers", - [ - {"Signature-Agent": 'sig1="https://buyer.com"'}, - {"Signature-Input": 'sig1=("@method");tag="web-bot-auth"'}, - ], -) -def test_legacy_verifier_never_attributes_or_emits_legacy_codes_for_wba(headers): - from unittest.mock import Mock - - from adcp.signing import VerifierCapability, VerifyOptions, verify_request_signature - - resolver = Mock() - with pytest.raises(WbaVerificationError) as caught: - verify_request_signature( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"", - options=VerifyOptions( - now=1000, - capability=VerifierCapability(), - operation="get_products", - jwks_resolver=resolver, - ), - ) - assert caught.value.status == 403 - assert "Accept-Signature" in caught.value.headers - resolver.assert_not_called() - - -@pytest.mark.parametrize("cover_extra", [False, True]) -async def test_relay_covers_every_component_of_earlier_signature(cover_extra): - from http_sfv.item import Item - - from adcp.signing.crypto import sign_signature_base - from adcp.signing.wba import _base, _dictionary - - vector = json.loads((ROOT / "positive/002-relay-request-two-signatures.json").read_text()) - request = vector["request"] - headers = request["headers"] - headers["X-Buyer-Reference"] = "campaign-123" - inputs = _dictionary(headers["Signature-Input"]) - signatures = _dictionary(headers["Signature"]) - labels = list(inputs) - inputs[labels[0]].append(Item("x-buyer-reference")) - if cover_extra: - inputs[labels[1]].append(Item("x-buyer-reference")) - headers["Signature-Input"] = str(inputs) - for label, member in inputs.items(): - key = KEYS[member.params["keyid"]] - signatures[label] = Item( - sign_signature_base( - alg="ed25519", - private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), - signature_base=_base(request["method"], request["url"], headers, member), - ) - ) - headers["Signature"] = str(signatures) - - async def resolve(keyid, *, signer_origin=None): - key = KEYS[keyid] - return ResolvedSignerKey(signer_origin, key) if key["identity"] == signer_origin else None - - async def verify(): - return await verify_wba_request( - method=request["method"], - url=request["url"], - headers=headers, - body=request["body"].encode(), - signer_keys=resolve, - replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, - now=vector["reference_now"], - ) - - if cover_extra: - assert len(await verify()) == 2 - else: - with pytest.raises(WbaVerificationError, match="covered component"): - await verify() - - -@pytest.mark.parametrize("revocation_state", ["clear", "revoked", "unavailable"]) -async def test_framework_checks_origin_revocations_by_default(monkeypatch, revocation_state): - from datetime import datetime, timedelta, timezone - - from adcp.signing import OriginRevocationChecker - from adcp.signing.crypto import b64url_encode - from adcp.signing.revocation_fetcher import FetchResult - - origin = "https://buyer.example.com" - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - now = datetime.now(timezone.utc) - payload = { - "version": 1, - "issuer": origin, - "updated": now.isoformat(), - "next_update": (now + timedelta(minutes=5)).isoformat(), - "revoked_kids": [jwk["kid"]] if revocation_state == "revoked" else [], - "revoked_jtis": [], - } - protected = b64url_encode( - json.dumps({"alg": "EdDSA", "kid": jwk["kid"], "typ": "adcp-gov-revocation+jws"}).encode() - ) - encoded = b64url_encode(json.dumps(payload).encode()) - signature = b64url_encode(private.sign(f"{protected}.{encoded}".encode())) - calls, dispatched = [], [] - - async def fetch(uri, **kwargs): - calls.append(uri) - if revocation_state == "unavailable": - raise OSError("offline") - return FetchResult(f"{protected}.{encoded}.{signature}", etag=None) - - monkeypatch.setattr( - "adcp.signing.revocation_fetcher.async_default_revocation_list_fetcher", fetch - ) - bindings = OriginBindings() - bindings.bind(origin, "principal-1") - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({origin: {"keys": [jwk]}}), - enable_web_bot_auth=True, - origin_bindings=bindings, - trust_records=_trust_records(origin), - ) - assert isinstance(config.wba_revocation_checker, OriginRevocationChecker) - - async def dispatch(scope, receive, send): - context = apply_verified_signer(None, Request(scope)) - try: - check_wba_authorization(context) - except AdcpError as exc: - await JSONResponse(build_mcp_error_result(exc).model_dump(mode="json", by_alias=True))( - scope, receive, send - ) - return - dispatched.append(True) - await JSONResponse({"ok": True})(scope, receive, send) - - app = SignedRequestVerificationMiddleware( - dispatch, config, transport="mcp", defer_authorization_errors=True - ) - body = ( - b'{"jsonrpc":"2.0","id":1,"method":"tools/call",' - b'"params":{"name":"sync_accounts","arguments":{}}}' - ) - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url="https://seller.example.com/mcp", - headers=headers, - body=body, - private_key=private, - agent_origin=origin, - ) - ) - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app), base_url="https://seller.example.com" - ) as client: - response = await client.post("/mcp", content=body, headers=headers) - assert calls == [origin + "/.well-known/governance-revocations.json"] - assert bool(dispatched) == (revocation_state == "clear") - assert response.status_code == 200, response.text - if revocation_state == "revoked": - error = _mcp_adcp_error(response) - assert error["code"] == "PERMISSION_DENIED" - assert error["message"] == "Authentication failed" - assert "Accept-Signature" not in response.headers - elif revocation_state == "unavailable": - error = _mcp_adcp_error(response) - assert error["code"] == "SERVICE_UNAVAILABLE" - assert error["recovery"] == "transient" - assert "Accept-Signature" not in response.headers diff --git a/tests/conformance/signing/test_wba_profile_selection.py b/tests/conformance/signing/test_wba_profile_selection.py deleted file mode 100644 index 15c9c3c88..000000000 --- a/tests/conformance/signing/test_wba_profile_selection.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Opt-in WBA signing follows advertised profiles and never silently downgrades.""" - -from types import SimpleNamespace - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric import ed25519 - -from adcp import ADCPClient, AgentConfig -from adcp.signing import ( - SigningConfig, - install_signing_event_hook, - public_directory_key, - signing_operation, -) -from adcp.types.experimental_identity import RequestSigning - - -@pytest.mark.parametrize("style", ["client", "httpx"]) -@pytest.mark.parametrize( - "profiles", - [ - None, - ["adcp/request-signing/v1"], - ["web-bot-auth"], - ["adcp/request-signing/v1", "web-bot-auth"], - ], -) -async def test_wba_signer_requires_advertised_profile_before_network(style, profiles): - private = ed25519.Ed25519PrivateKey.generate() - signing = SigningConfig( - private_key=private, - key_id=public_directory_key(private)["kid"], - agent_origin="https://buyer.com", - ) - capability = RequestSigning( - supported=True, required_for=["create_media_buy"], profiles=profiles - ) - sent = [] - - async def send(request): - sent.append(request) - return httpx.Response(200, json={"ok": True}) - - async with httpx.AsyncClient( - transport=httpx.MockTransport(send), base_url="https://seller.com" - ) as client: - if style == "client": - sdk = ADCPClient( - AgentConfig(id="seller", agent_uri="https://seller.com", protocol="a2a"), - signing=signing, - ) - sdk._capabilities = SimpleNamespace(request_signing=capability) - client.event_hooks["request"].append(sdk._sign_outgoing_request) - else: - install_signing_event_hook(client, signing=signing, seller_capability=capability) - with signing_operation("create_media_buy"): - if profiles is None or "web-bot-auth" not in profiles: - with pytest.raises(ValueError, match="does not advertise"): - await client.post("/mcp", json={"budget": 100}) - assert not sent - else: - assert (await client.post("/mcp", json={"budget": 100})).status_code == 200 - assert len(sent) == 1 - assert 'tag="web-bot-auth"' in sent[0].headers["Signature-Input"] - assert sent[0].headers["Signature-Agent"] == 'sig1="https://buyer.com"' diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md deleted file mode 100644 index 55045441e..000000000 --- a/tests/conformance/vectors/wba-profile/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Experimental Web Bot Auth vectors - -Copied without modification from adcontextprotocol/adcp PR #7894, commit -`656bd080f01cc2fca4a1e623af44e96cfd827ba5`, -`static/compliance/source/test-vectors/wba-profile/` on 2026-10-13. - -These are draft transport vectors, not released AdCP 3.3 compliance inputs. -Governance JWS remains on the RFC 9421 chain and is outside this preview. -The public private seeds in keys.json are test inputs only. diff --git a/tests/conformance/vectors/wba-profile/keys.json b/tests/conformance/vectors/wba-profile/keys.json deleted file mode 100644 index 3174c7048..000000000 --- a/tests/conformance/vectors/wba-profile/keys.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$comment": "Public test keys for the Web Bot Auth profile conformance vectors. The private seeds are published so that implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string 'rfc004-test/'. Web Bot Auth section 6.8: test keys MUST NOT be used in production, and verifiers SHOULD reject them when detected. Each kid is the RFC 7638 JWK thumbprint (RFC 8037 Appendix A.3 for Ed25519). Each entry's identity is the origin whose key directory publishes the key.", - "keys": [ - { - "identity": "https://buyer-7k3q.com", - "seed_name": "buyer-request", - "kty": "OKP", - "crv": "Ed25519", - "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", - "x": "yIxM3SIY89_Yhr6EA9hik-AJjWSDPraK5chDqQ3Xlf0", - "use": "sig", - "_private_d_for_test_only": "qgZV0LEd-ZDGdPdhM182B1YmHy0OAsNAEZT-feYyfbU" - }, - { - "identity": "https://agent.brand-7k3q.com", - "seed_name": "brand-request", - "kty": "OKP", - "crv": "Ed25519", - "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", - "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", - "use": "sig", - "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" - }, - { - "identity": "https://relay.agency-7k3q.com", - "seed_name": "relay-request", - "kty": "OKP", - "crv": "Ed25519", - "kid": "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ", - "x": "8nrvWz557SHSlmuXE2dMstsU_WmRQNze3VIIeNhN-VE", - "use": "sig", - "_private_d_for_test_only": "9HMKspy6Bjt8vLzngwm2L1pzrbxOn6A7nTDBRhmRYOE" - }, - { - "identity": "https://governance-7k3q.com", - "seed_name": "governance-document", - "kty": "OKP", - "crv": "Ed25519", - "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", - "x": "k-Q_ekTZQ_Hv6lLlIUYfE7jpJSCZ5nsLDfeoLIAkrL4", - "use": "sig", - "_private_d_for_test_only": "nfv0d9-TrrE1ajrAPEK-2yPoG9PHHbex-aqnv4xVnKQ" - } - ] -} diff --git a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json deleted file mode 100644 index 9a86106fc..000000000 --- a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "Signature-Input omits the nonce parameter", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#replay-protection", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "sig1=\"https://buyer-7k3q.com\"", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "sig1=:UVUnZDwMf7zowtYHn4j4JKNdousAspU+lluY4Y8msRL1AB8QN+xrB+aJaOfFSxrVCnwWnMDNezEuyXiJvoU+AA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://buyer-7k3q.com": { - "keys_ref": [ - "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" - ] - } - }, - "expected_signature_base": { - "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig1\": \"https://buyer-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "sig1", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The profile requires an RFC 9421 nonce on every signed request. Without one the verifier cannot detect replay." - }, - "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403 with Accept-Signature listing nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json deleted file mode 100644 index f22908699..000000000 --- a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "Second submission of a request whose (identity, nonce) pair the verifier has already accepted", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#replay-protection", - "signing_profile": "web-bot-auth", - "reference_now": 1790841615, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "sig1=\"https://buyer-7k3q.com\"", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "sig1=:ihPGBC7XQPM6WkIYhYQvFHgwTpbssvuQzkb0FfwkuebREyF32sXhiRxXbXWRZMp0O//C4I2g1a2Kui56ffEfBQ==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://buyer-7k3q.com": { - "keys_ref": [ - "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" - ] - } - }, - "test_harness_state": { - "$comment": "Preload the replay cache as if positive/001 was just verified. White-box harnesses inject the entry; black-box runners send positive/001 twice and grade the second response.", - "replay_cache_entries": [ - { - "identity": "https://buyer-7k3q.com", - "nonce": "Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA", - "ttl_seconds": 360 - } - ] - }, - "expected_signature_base": { - "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig1\": \"https://buyer-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "sig1", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth", - "nonce": "verifier-chosen" - }, - "reason": "The verifier MUST reject a nonce it has already seen for the same identity within the signature's lifetime." - }, - "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The 403 carries Accept-Signature with a verifier-chosen nonce value (RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json deleted file mode 100644 index 3417218b6..000000000 --- a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "Signature-Agent header present, but the sig1 member is not a covered component", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#profile-rules", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "sig1=\"https://buyer-7k3q.com\"", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1790841600;expires=1790841900;nonce=\"7zKVsUsDkwbtvncoToQq1fW7IyIQrbSC3ZY-tFE2mdNOSKRmoZ7NEqEuwZsFZKg61b7KK0nXkR7AlA6_WFZJ9A\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "sig1=:e9r9FdXp1AxLJv4Lzg03+tRR1RXafvY7QjEd8DrkN29WHN5etGgyPI5ruhoYvuMqJbN1FAnmtkAfl/UG0BIpAw==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://buyer-7k3q.com": { - "keys_ref": [ - "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" - ] - } - }, - "expected_signature_base": { - "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1790841600;expires=1790841900;nonce=\"7zKVsUsDkwbtvncoToQq1fW7IyIQrbSC3ZY-tFE2mdNOSKRmoZ7NEqEuwZsFZKg61b7KK0nXkR7AlA6_WFZJ9A\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "sig1", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "Web Bot Auth section 5.2.1: the Signature-Agent member keyed to the signature label MUST be signed as a component. An uncovered member is an unverified claim and MUST NOT be attributed (section 4.1)." - }, - "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json b/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json deleted file mode 100644 index eebbaa208..000000000 --- a/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Relay adds a second signature that does not cover the brand agent's signature members", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\");created=1790841601;expires=1790841901;nonce=\"pFCSjj_rt5LZuG-V4YvQAcOlDBrfb8Y35GQsMHTcpO99SLTcz-osapOLlF7767B6CyiDfZCertLo87_dfioQHg\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:2yPlznBMeRW3OuZL0nTEiAre21tcqZH04+dfLsykHH+5FOQzdACO+Bf7i1CYQE24TWrz5NallQc2+yr+ehX1CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\");created=1790841601;expires=1790841901;nonce=\"pFCSjj_rt5LZuG-V4YvQAcOlDBrfb8Y35GQsMHTcpO99SLTcz-osapOLlF7767B6CyiDfZCertLo87_dfioQHg\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "relay", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The profile requires each later signer to cover the signature-agent, signature-input, and signature members of the signature before it. The relay label covers none of the brand label's members." - }, - "$comment": "Both signatures verify on their own, so a verifier that checks only cryptographic validity accepts the request. The relay's signature then records nothing about the brand agent's signature having been present, which is the evidence the profile requires of a relay (Web Bot Auth section 5.2.2 and Appendix D.2). The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json b/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json deleted file mode 100644 index 7fa9df29b..000000000 --- a/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Brand agent's signature altered after the relay signed over it", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:AQHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:ra29HdXx7Hl46Tb9bx+8hhZQ8sL7xeeIC0kaWoUTS7qZyjSd9IyZa7jFcdbjkDFMfnmhHtu6RD+WFZ84Tz78CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"signature-input\";key=\"brand\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"\n\"signature\";key=\"brand\": :AQHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "relay", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The relay label claims to cover \"signature\";key=\"brand\", but the value on the wire is not the one the relay signed, so the relay's signature fails. The brand label's signature fails as well." - }, - "$comment": "Derived from positive/002-relay-request-two-signatures.json by changing the first character of the brand signature. Every web-bot-auth signature on a request must verify, so one failure rejects the request. The vector shows that the relay's signature binds the brand agent's signature bytes: changing or removing them breaks the relay's signature too. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json b/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json deleted file mode 100644 index 62d51df5d..000000000 --- a/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "Signature-Agent names https://agent.brand-7k3q.com, but the signing key is published only by https://buyer-7k3q.com", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#identity", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "sig1=\"https://agent.brand-7k3q.com\"", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"15d5LKMrMWxIft5nP6rJsDWVqgTBbw9DzLKwwhuUxo1z3-g6-8bDFzVoRclAeUlMmVf26Fnu7wdcUTrNkgV3Ww\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "sig1=:FjpxFqszIgxRRNWrZZ2l6ZIAvkagW7DZulhHwd40YoKdPch9oFl+6/Nm87uuDaDOKk2Ult8AUn3eQgLUxXQ1AA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://buyer-7k3q.com": { - "keys_ref": [ - "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" - ] - } - }, - "expected_signature_base": { - "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig1\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"15d5LKMrMWxIft5nP6rJsDWVqgTBbw9DzLKwwhuUxo1z3-g6-8bDFzVoRclAeUlMmVf26Fnu7wdcUTrNkgV3Ww\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "sig1", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "Key lookup is keyed on the (origin, keyid) pair, never on keyid alone (Web Bot Auth section 5.4). The directory at the named origin holds no key with this thumbprint, so the request is unverified and MUST NOT be attributed to that origin (section 6.10)." - }, - "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json b/tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json deleted file mode 100644 index bd29348b6..000000000 --- a/tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "Signature-Agent is not a parseable Structured Field Dictionary (unterminated string)", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#error-responses", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "sig1=\"https://buyer-7k3q.com", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://buyer-7k3q.com": { - "keys_ref": [ - "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" - ] - } - }, - "expected_outcome": { - "success": false, - "status": 400, - "reason": "Web Bot Auth section 5.4: a verifier that fails to parse Signature, Signature-Input, or Signature-Agent answers 400 Bad Request." - }, - "$comment": "Signature is a 64-zero-byte placeholder: parsing fails before any key is resolved or any signature is checked, so a verifier that reaches cryptographic verification has skipped the parse step. No Accept-Signature is sent, because the signer's headers, not its signature, need fixing." -} diff --git a/tests/conformance/vectors/wba-profile/positive/001-single-signer-request.json b/tests/conformance/vectors/wba-profile/positive/001-single-signer-request.json deleted file mode 100644 index 8069b04e5..000000000 --- a/tests/conformance/vectors/wba-profile/positive/001-single-signer-request.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "Single signer: buyer agent at https://buyer-7k3q.com sends create_media_buy over MCP", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#profile-rules", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "sig1=\"https://buyer-7k3q.com\"", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "sig1=:ihPGBC7XQPM6WkIYhYQvFHgwTpbssvuQzkb0FfwkuebREyF32sXhiRxXbXWRZMp0O//C4I2g1a2Kui56ffEfBQ==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://buyer-7k3q.com": { - "keys_ref": [ - "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" - ] - } - }, - "expected_signature_base": { - "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig1\": \"https://buyer-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": true, - "identities": { - "sig1": "https://buyer-7k3q.com" - } - }, - "$comment": "The Signature-Agent member sig1 names an origin, so the verifier fetches https://buyer-7k3q.com/.well-known/http-message-signatures-directory, selects the key whose kid equals keyid, and attributes the request to that origin. Ed25519 is deterministic, so an implementation signing with the published seed reproduces the Signature value byte for byte." -} diff --git a/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json b/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json deleted file mode 100644 index 9ceeb670b..000000000 --- a/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "Relay: brand agent signs, agency relay forwards unchanged and adds a signature covering the brand agent's members", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:ra29HdXx7Hl46Tb9bx+8hhZQ8sL7xeeIC0kaWoUTS7qZyjSd9IyZa7jFcdbjkDFMfnmhHtu6RD+WFZ84Tz78CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"signature-input\";key=\"brand\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"\n\"signature\";key=\"brand\": :7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": true, - "identities": { - "brand": "https://agent.brand-7k3q.com", - "relay": "https://relay.agency-7k3q.com" - } - }, - "$comment": "Both web-bot-auth signatures must verify, each against its own covered components and its own identity's directory (Web Bot Auth section 5.2.2). The relay label covers the brand label's signature-agent, signature-input, and signature members, as section 5.2.2 requires of a signer that covers another signature and as Appendix D.2 illustrates. Whether the relay may act for the brand is an authorization question answered by the brand's brand.json, outside this profile." -} diff --git a/tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json b/tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json deleted file mode 100644 index 30298f092..000000000 --- a/tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "Signed key directory response for https://buyer-7k3q.com, one signature per key", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#key-directories", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "GET", - "url": "https://buyer-7k3q.com/.well-known/http-message-signatures-directory", - "headers": { - "Host": "buyer-7k3q.com", - "Accept": "application/http-message-signatures-directory+json" - }, - "body": "" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/http-message-signatures-directory+json", - "Cache-Control": "max-age=300", - "Content-Digest": "sha-256=:RQDAkRyNAdoJ2+MThuVDCXhj13Or2jS6ewwSRuyaXQg=:", - "Signature-Input": "key1=(\"@authority\";req \"content-digest\");created=1790841000;expires=1790927400;keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"", - "Signature": "key1=:QJeeYQ9E5OXMbEy89f1YjQNCB5NjRxN+ck/V+4sRoVNtF5S/446uVIECNCeANo6YH/DhOBG/jMzNZLDqQrmkBg==:" - }, - "body": "{\n \"keys\": [\n {\n \"kty\": \"OKP\",\n \"crv\": \"Ed25519\",\n \"kid\": \"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\",\n \"x\": \"yIxM3SIY89_Yhr6EA9hik-AJjWSDPraK5chDqQ3Xlf0\",\n \"use\": \"sig\"\n }\n ]\n}" - }, - "expected_signature_base": { - "key1": "\"@authority\";req: buyer-7k3q.com\n\"content-digest\": sha-256=:RQDAkRyNAdoJ2+MThuVDCXhj13Or2jS6ewwSRuyaXQg=:\n\"@signature-params\": (\"@authority\";req \"content-digest\");created=1790841000;expires=1790927400;keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"" - }, - "expected_outcome": { - "success": true, - "identities": { - "key1": "https://buyer-7k3q.com" - } - }, - "$comment": "Web Bot Auth Appendix B.1: the directory server signs the response once per key, covering \"@authority\";req and content-digest, with created, expires, the key's thumbprint as keyid, and tag http-message-signatures-directory. The verifier validates the signature with the key the directory itself provides and validates Content-Digest against the body. Covering the request authority stops the key set from being served again from another domain. The verifier stores the signed response as its audit record of the fetch." -} diff --git a/tests/test_a2a_server.py b/tests/test_a2a_server.py index f912bad44..edb571983 100644 --- a/tests/test_a2a_server.py +++ b/tests/test_a2a_server.py @@ -511,7 +511,7 @@ async def get_products(self, params: Any, context: Any = None) -> Any: async def test_cancel(): - """Cancel updates the existing task instead of replacing it.""" + """Cancel returns a canceled task.""" executor = ADCPAgentExecutor(_TestHandler()) ctx = RequestContext(task_id="t1", context_id="c1") queue = EventQueue() @@ -519,7 +519,7 @@ async def test_cancel(): await executor.cancel(ctx, queue) event = await queue.dequeue_event() - assert isinstance(event, pb.TaskStatusUpdateEvent) + assert isinstance(event, Task) assert event.status.state == pb.TaskState.TASK_STATE_CANCELED diff --git a/tests/test_experimental_identity.py b/tests/test_experimental_identity.py deleted file mode 100644 index a6dc46266..000000000 --- a/tests/test_experimental_identity.py +++ /dev/null @@ -1,675 +0,0 @@ -"""Trust records, grant chains, revocation retention and experimental types.""" - -from __future__ import annotations - -import json -from datetime import datetime, timezone - -import pytest - -from adcp.decisioning.trust_grants import TrustGrantClaim, TrustGrantGate -from adcp.decisioning.types import AdcpError -from adcp.signing import ( - RevocationDetails, - TrustGrantEvaluator, - TrustRecordAbsentError, - TrustRecordError, - TrustRecordEvaluator, - TrustResponse, - trust_record_url, - validate_trust_record, -) -from adcp.signing.revocation import RevocationList -from adcp.types.experimental_identity import ( - AccountCapabilities, - BuyerOnboardingPosture, - GetPrincipalResponse, - GrantAbsentDetails, - PrincipalChangedWebhook, - TrustRecord, -) - -ORIGIN = "https://buyer.platform.com" -SIGNER_RECORD = {"agents": [{"url": ORIGIN + "/mcp", "roles": ["adcp:buying"]}]} - - -def records(documents, clock=lambda: 1000): - calls = [] - - async def fetch(url, *, allow_private=False): - calls.append(url) - document = documents.get(url) - if isinstance(document, Exception): - raise document - if isinstance(document, TrustResponse): - return document - return ( - TrustResponse(json.dumps(document).encode()) - if document is not None - else TrustResponse(b"", 404) - ) - - return TrustRecordEvaluator(fetcher=fetch, clock=clock), calls - - -async def test_trust_record_expiring_during_fetch_never_grants_implicit_authority(): - now = [1000] - - async def fetch(url, *, allow_private=False): - now[0] += 61 - return TrustResponse(json.dumps(SIGNER_RECORD).encode()) - - evaluator = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - with pytest.raises(TrustRecordError, match="expired"): - await evaluator.evaluate(ORIGIN, role="adcp:buying") - result = await TrustGrantEvaluator(evaluator).check( - agent_origin=ORIGIN, - brand_domain="platform.com", - account_operator="platform.com", - ) - assert not result.authorized - - -@pytest.mark.parametrize( - "url,expected", - [ - ("https://buyer.agency.co.uk/mcp", "https://agency.co.uk/.well-known/trust.json"), - ("https://buyer.team.github.io/mcp", "https://team.github.io/.well-known/trust.json"), - ("https://xn--bcher-kva.de/mcp", "https://xn--bcher-kva.de/.well-known/trust.json"), - ], -) -def test_pinned_psl(url, expected): - assert trust_record_url(url) == expected - - -@pytest.mark.parametrize("origin", ["https://localhost/mcp", "https://127.0.0.1", "https://co.uk"]) -def test_no_operator_domain(origin): - with pytest.raises(TrustRecordError): - trust_record_url(origin) - - -@pytest.mark.parametrize( - "document", - [ - {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"], "jwks_uri": ORIGIN + "/keys"}]}, - { - "agents": [ - {"url": ORIGIN, "roles": ["adcp:buying"]}, - {"url": ORIGIN + "/mcp", "roles": ["adcp:sales"]}, - ] - }, - {"agents": [{"url": "https://attacker.com", "roles": ["adcp:buying"]}]}, - {"agents": [{"url": "https://user@platform.com", "roles": ["adcp:buying"]}]}, - {"grants": [{"grantee": "seat.agency.com", "scopes": ["adcp:media_buying"]}]}, - ], -) -def test_invalid_records(document): - with pytest.raises(TrustRecordError): - validate_trust_record(document, operator_domain="platform.com") - - -async def test_hosted_record_preserves_owner_and_requires_role(): - evaluator, calls = records( - { - trust_record_url(ORIGIN): {"authoritative_location": "https://hosting.com/trust.json"}, - "https://hosting.com/trust.json": SIGNER_RECORD, - } - ) - record = await evaluator.evaluate(ORIGIN, role="adcp:buying") - assert record.operator_domain == "platform.com" - assert len(calls) == 2 - record.document["agents"].clear() - assert (await evaluator.evaluate(ORIGIN, role="adcp:buying")).agent(ORIGIN, role="adcp:buying") - with pytest.raises(TrustRecordError, match="role"): - await evaluator.evaluate(ORIGIN, role="adcp:governance") - - -async def test_no_second_hop_or_http_redirect(): - evaluator, _ = records( - { - trust_record_url(ORIGIN): {"authoritative_location": "https://hosting.com/trust.json"}, - "https://hosting.com/trust.json": { - "authoritative_location": "https://third.com/trust.json" - }, - } - ) - with pytest.raises(TrustRecordError, match="second"): - await evaluator.evaluate(ORIGIN) - evaluator, _ = records({trust_record_url(ORIGIN): TrustResponse(b"", 302)}) - with pytest.raises(TrustRecordError): - await evaluator.evaluate(ORIGIN) - - -@pytest.mark.parametrize( - "body", [b'{"agents":[],"agents":[]}', b'{"agents":NaN}', b" " * (2 * 1024 * 1024 + 1)] -) -async def test_strict_json_and_size_cap(body): - evaluator, _ = records({trust_record_url(ORIGIN): TrustResponse(body)}) - with pytest.raises(TrustRecordError): - await evaluator.evaluate(ORIGIN) - - -async def test_expired_record_fails_closed_and_cannot_downgrade(): - now = [1000] - documents = {trust_record_url(ORIGIN): SIGNER_RECORD} - evaluator, calls = records(documents, lambda: now[0]) - await evaluator.evaluate(ORIGIN) - await evaluator.evaluate(ORIGIN) - assert len(calls) == 1 - now[0] = 1061 - documents[trust_record_url(ORIGIN)] = OSError("offline") - with pytest.raises(TrustRecordError): - await evaluator.evaluate(ORIGIN) - documents[trust_record_url(ORIGIN)] = None - with pytest.raises(TrustRecordError) as exc: - await evaluator.evaluate(ORIGIN) - assert not isinstance(exc.value, TrustRecordAbsentError) - - -def grant(grantee, **changes): - return {"grantee": grantee, "scopes": ["adcp:media_buying"], **changes} - - -async def check(documents, operator="agency.com", **changes): - evaluator, _ = records({trust_record_url(ORIGIN): SIGNER_RECORD, **documents}) - return await TrustGrantEvaluator(evaluator, clock=lambda: 1000).check( - agent_origin=ORIGIN, brand_domain="brand.com", account_operator=operator, **changes - ) - - -async def test_implicit_and_direct_grants_need_operator_on_chain(): - evaluator, _ = records({trust_record_url(ORIGIN): SIGNER_RECORD}) - assert ( - await TrustGrantEvaluator(evaluator).check( - agent_origin=ORIGIN, brand_domain="platform.com", account_operator="platform.com" - ) - ).authorized - docs = {trust_record_url("brand.com"): {"grants": [grant("platform.com")]}} - assert (await check(docs, operator="platform.com")).authorized - assert not (await check(docs)).authorized - - -async def test_two_link_intersection_and_expiration(): - docs = { - trust_record_url("brand.com"): { - "grants": [ - grant( - "agency.com", - countries=["US", "CA"], - profiles={"adcp": {"brands": ["brand_1"]}}, - valid_until="1970-01-01T00:30:00Z", - ) - ] - }, - trust_record_url("agency.com"): { - "grants": [ - grant( - "platform.com", - countries=["US"], - agents=[{"url": ORIGIN}], - valid_until="1970-01-01T00:20:00Z", - ) - ] - }, - } - accepted = await check(docs, brand_id="brand_1", countries=["US"]) - assert accepted.authorized and accepted.expires_at <= 1200 - assert not (await check(docs, brand_id="other", countries=["US"])).authorized - assert not (await check(docs, brand_id="brand_1", countries=["CA"])).authorized - assert not (await check(docs, brand_id="brand_1", countries=[])).authorized - docs[trust_record_url("agency.com")]["grants"][0]["valid_until"] = "1970-01-01T00:16:40Z" - assert not (await check(docs, brand_id="brand_1", countries=["US"])).authorized - - -async def test_never_follow_three_links_or_operator_record_as_brand_authority(): - docs = { - trust_record_url("brand.com"): {"grants": [grant("agency.com")]}, - trust_record_url("agency.com"): {"grants": [grant("middle.com")]}, - trust_record_url("middle.com"): {"grants": [grant("platform.com")]}, - } - assert not (await check(docs)).authorized - docs[trust_record_url("brand.com")] = {"agents": []} - docs[trust_record_url("agency.com")] = {"grants": [grant("platform.com")]} - denied = await check(docs) - assert not denied.authorized and not denied.legacy_fallback_allowed - with pytest.raises(AdcpError) as exc: - denied.require() - assert exc.value.details == {"scope": "agent", "reason": "grant_absent"} - - -async def test_exclusive_scopes_and_no_legacy_after_200(): - docs = { - trust_record_url(ORIGIN): SIGNER_RECORD, - trust_record_url("brand.com"): {"grants": [grant("other.com")], "exclusive_grants": True}, - } - called = [] - - async def authorize(request): - called.append(request) - return True - - evaluator, _ = records(docs) - authz = TrustGrantEvaluator( - evaluator, - clock=lambda: 1000, - seller_authorization=authorize, - legacy_authorization=authorize, - ) - args = dict(agent_origin=ORIGIN, brand_domain="brand.com", account_operator="agency.com") - assert not (await authz.check(**args)).authorized and not called - docs[trust_record_url("brand.com")]["exclusive_grants"] = False - evaluator, _ = records(docs) - authz = TrustGrantEvaluator( - evaluator, - clock=lambda: 1000, - seller_authorization=authorize, - legacy_authorization=authorize, - ) - assert (await authz.check(**args)).authorized and len(called) == 1 - - -async def test_unestablished_grant_gate_omits_oracle_details(): - evaluator, _ = records({}) - gate = TrustGrantGate( - TrustGrantEvaluator(evaluator), - lambda account, agent: TrustGrantClaim("brand.com", "agency.com"), - ) - with pytest.raises(AdcpError) as exc: - await gate.require_claim(TrustGrantClaim("brand.com", "agency.com"), None) - assert exc.value.details == {} - - -def test_preview_round_trips(): - response = GetPrincipalResponse.model_validate( - { - "result": { - "kind": "unconfigured", - "relationship": {"status": "pending", "setup": {"message": "Apply"}}, - } - } - ) - assert response.result.relationship.status == "pending" - assert ( - response.model_dump(mode="json", exclude_none=True)["result"]["relationship"]["setup"][ - "message" - ] - == "Apply" - ) - account = AccountCapabilities( - supported_billing=["operator"], sandbox=True, agent_onboarding={"sandbox": {"mode": "open"}} - ) - assert account.agent_onboarding.sandbox.mode == "open" - assert BuyerOnboardingPosture(mode="application").mode == "application" - assert GrantAbsentDetails().model_dump() == {"scope": "agent", "reason": "grant_absent"} - validate_trust_record( - TrustRecord.model_validate(SIGNER_RECORD).model_dump(mode="json", exclude_none=True), - operator_domain="platform.com", - ) - validate_trust_record( - { - "agents": [ - { - "url": ORIGIN, - "roles": ["adcp:buying"], - "profiles": {"adcp": {"onboarding": {"mode": "application"}}}, - } - ] - }, - operator_domain="platform.com", - ) - - -@pytest.mark.parametrize("reason", ["relationship_changed", "relationship_expiring"]) -def test_principal_webhook_reasons(reason): - payload = { - "idempotency_key": "0123456789abcdef", - "notification_id": "n1", - "fired_at": "2026-10-13T00:00:00Z", - "subscriber_id": "s1", - "agent_url": "https://seller.com", - "changed_at": "2026-10-13T00:00:00Z", - "reason": reason, - } - assert ( - PrincipalChangedWebhook.model_validate(payload).model_dump(mode="json")["reason"] == reason - ) - - -def test_revocation_retains_compromise_and_earliest_date(): - base = { - "issuer": ORIGIN, - "updated": "2026-10-13T00:00:00Z", - "next_update": "2026-10-13T00:01:00Z", - } - first = RevocationList.from_dict( - { - **base, - "revoked_kids": ["kid"], - "revocation_details": { - "kid": {"reason": "keyCompromise", "invalidity_date": "2026-10-11T00:00:00Z"} - }, - } - ) - newer = RevocationList.from_dict( - { - **base, - "revoked_kids": ["kid"], - "revocation_details": { - "kid": {"reason": "superseded", "invalidity_date": "2026-10-12T00:00:00Z"} - }, - } - ) - newer.retain(first) - assert newer.revocation_details["kid"].reason == "keyCompromise" - assert newer.revocation_details["kid"].invalidity_date.day == 11 - withdrawn = RevocationList.from_dict(base) - withdrawn.retain(newer) - assert withdrawn.is_revoked("kid") - assert withdrawn.revocation_details["kid"].exposes(datetime(2026, 10, 11, tzinfo=timezone.utc)) - assert not withdrawn.revocation_details["kid"].exposes( - datetime(2026, 10, 10, tzinfo=timezone.utc) - ) - assert RevocationDetails(reason="keyCompromise").exposes(datetime.now(timezone.utc)) - - -async def test_grants_checked_before_account_upsert_and_existing_account_calls(): - from concurrent.futures import ThreadPoolExecutor - from types import SimpleNamespace - - from adcp.decisioning import ( - AuthInfo, - BuyerAgent, - DecisioningCapabilities, - DecisioningPlatform, - HttpSigCredential, - InMemoryTaskRegistry, - SingletonAccounts, - signing_only_registry, - ) - from adcp.decisioning.handler import PlatformHandler - from adcp.server import ToolContext - from adcp.types import GetProductsRequest - - touched = [] - - class Accounts(SingletonAccounts): - def upsert_request(self, request, ctx=None): - touched.append("upsert") - return {"accounts": []} - - class Platform(DecisioningPlatform): - capabilities = DecisioningCapabilities() - accounts = Accounts(account_id="existing") - - async def get_products(self, req, ctx): - touched.append("products") - return {"products": []} - - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") - - async def lookup(agent_url): - return buyer - - evaluator, _ = records( - {trust_record_url(ORIGIN): SIGNER_RECORD, trust_record_url("brand.com"): {"agents": []}} - ) - gate = TrustGrantGate( - TrustGrantEvaluator(evaluator, clock=lambda: 1000), - lambda account, agent: TrustGrantClaim("brand.com", "platform.com"), - ) - auth = AuthInfo( - kind="http_sig", - credential=HttpSigCredential( - kind="http_sig", keyid="kid", agent_url=ORIGIN, verified_at=1000 - ), - ) - with ThreadPoolExecutor(max_workers=1) as executor: - handler = PlatformHandler( - Platform(), - executor=executor, - registry=InMemoryTaskRegistry(), - buyer_agent_registry=signing_only_registry(lookup), - trust_grant_gate=gate, - ) - context = ToolContext(metadata={"adcp.auth_info": auth}) - with pytest.raises(AdcpError) as exc: - await handler.sync_accounts( - SimpleNamespace( - accounts=[{"brand": {"domain": "brand.com"}, "operator": "platform.com"}] - ), - context, - ) - assert exc.value.details["reason"] == "grant_absent" - assert not touched - with pytest.raises(AdcpError): - await handler.get_products( - GetProductsRequest(buying_mode="brief", brief="any"), context - ) - assert not touched - - -async def test_client_opt_in_preserves_relationship_response(monkeypatch): - from unittest.mock import AsyncMock - - from adcp import ADCPClient, AgentConfig - from adcp.types import GetPrincipalRequest - from adcp.types.core import TaskResult, TaskStatus - - payload = {"result": {"kind": "unconfigured", "relationship": {"status": "pending"}}} - client = ADCPClient( - AgentConfig(id="seller", agent_uri="https://seller.com/mcp", protocol="mcp"), - experimental_agent_identity=True, - ) - monkeypatch.setattr( - client.adapter, - "get_principal", - AsyncMock(return_value=TaskResult(status=TaskStatus.COMPLETED, success=True, data=payload)), - ) - result = await client.get_principal(GetPrincipalRequest()) - assert result.success - assert isinstance(result.data, GetPrincipalResponse) - assert result.data.result.relationship.status == "pending" - - -async def test_client_preserves_unknown_onboarding_requirements_and_signing_profiles(monkeypatch): - from unittest.mock import AsyncMock - - from adcp import ADCPClient, AgentConfig - from adcp.signing.trust import validate_identity_value - from adcp.types import GetAdcpCapabilitiesRequest - from adcp.types.core import TaskResult, TaskStatus - from adcp.types.experimental_identity import AgentOnboardingRequirementKind - - posture = { - "message": "Contact our onboarding team for this requirement", - "url": "https://seller.com/apply", - "sandbox": { - "mode": "application", - "requirements": [{"kind": "agent_identity"}, {"kind": "future_identity_badge"}], - }, - } - payload = { - "adcp": {"major_versions": [3], "idempotency": {"supported": False}}, - "supported_protocols": ["media_buy"], - "account": { - "supported_billing": ["operator"], - "sandbox": True, - "agent_onboarding": posture, - }, - "request_signing": { - "supported": True, - "covers_content_digest": "required", - "profiles": ["adcp/request-signing/v1", "web-bot-auth"], - }, - } - client = ADCPClient( - AgentConfig(id="seller", agent_uri="https://seller.com/mcp", protocol="mcp"), - experimental_agent_identity=True, - ) - monkeypatch.setattr( - client.adapter, - "get_adcp_capabilities", - AsyncMock(return_value=TaskResult(status=TaskStatus.COMPLETED, success=True, data=payload)), - ) - result = await client.get_adcp_capabilities(GetAdcpCapabilitiesRequest()) - assert result.success - onboarding = result.data.account.agent_onboarding - assert onboarding.sandbox.requirements[0].kind is AgentOnboardingRequirementKind.agent_identity - assert onboarding.sandbox.requirements[1].kind == "future_identity_badge" - assert onboarding.message == posture["message"] - assert str(onboarding.url) == posture["url"] - assert result.data.request_signing.profiles == payload["request_signing"]["profiles"] - # Publishing validation stays strict even when readers preserve future kinds. - with pytest.raises(TrustRecordError): - validate_identity_value(posture, "core/agent-onboarding.json") - - -@pytest.mark.parametrize("record_level", [False, True]) -async def test_trust_reader_retains_unknown_onboarding_requirements(record_level): - posture = { - "mode": "application", - "requirements": [{"kind": "future_identity_badge", "future_property": "opaque"}], - "message": "Ask a human to review this application", - "url": "https://platform.com/apply", - } - document = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - profiles = {"adcp": {"onboarding": posture}} - if record_level: - document["profiles"] = profiles - else: - document["agents"][0]["profiles"] = profiles - evaluator, _ = records({trust_record_url(ORIGIN): document}) - record = await evaluator.evaluate(ORIGIN, role="adcp:buying") - parsed_profiles = ( - record.document["profiles"] if record_level else record.document["agents"][0]["profiles"] - ) - assert parsed_profiles["adcp"]["onboarding"] == posture - with pytest.raises(TrustRecordError): - validate_trust_record(document, operator_domain="platform.com") - # Forward-compatible posture reading still rejects invalid agent authority. - document["agents"][0]["roles"] = ["unknown:authority"] - bad_evaluator, _ = records({trust_record_url(ORIGIN): document}) - with pytest.raises(TrustRecordError): - await bad_evaluator.evaluate(ORIGIN, role="adcp:buying") - - -async def test_relationship_hook_does_not_create_principal(): - from adcp.server.principal import ( - InMemoryPrincipalRecordStore, - PrincipalIdentity, - PrincipalService, - ) - - calls = [] - - async def relationship(identity): - calls.append(identity.subject) - return {"status": "pending", "setup": {"message": "Apply"}} - - store = InMemoryPrincipalRecordStore() - service = PrincipalService(store, relationship_resolver=relationship) - response = await service.get_principal( - PrincipalIdentity("https://buyer.platform.com", "buyer_agent") - ) - assert response.result.kind == "unconfigured" - assert response.model_dump(mode="json")["result"]["relationship"]["status"] == "pending" - assert calls == [ORIGIN] - assert await store.get(ORIGIN) is None - - -@pytest.mark.parametrize( - "target", ["https://hosting.com:99999/trust.json", "https://user@hosting.com/trust.json"] -) -async def test_untrusted_pointer_invalid_authority_is_a_closed_trust_failure(target): - evaluator, calls = records({trust_record_url(ORIGIN): {"authoritative_location": target}}) - with pytest.raises(TrustRecordError): - await evaluator.evaluate(ORIGIN) - assert len(calls) == 1 - - -async def test_trust_grants_replace_legacy_brand_authorization_gate(): - from concurrent.futures import ThreadPoolExecutor - - from adcp.decisioning import ( - AuthInfo, - BuyerAgent, - DecisioningCapabilities, - DecisioningPlatform, - HttpSigCredential, - InMemoryTaskRegistry, - SingletonAccounts, - signing_only_registry, - ) - from adcp.decisioning.brand_authz_gate import BrandAuthorizationGate, BrandIdentity - from adcp.decisioning.handler import PlatformHandler - from adcp.server import ToolContext - - class Platform(DecisioningPlatform): - capabilities = DecisioningCapabilities() - accounts = SingletonAccounts(account_id="existing") - - class Legacy: - async def is_authorized(self, **kwargs): - pytest.fail("brand.json must not authorize after a successful trust.json fetch") - - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") - - async def lookup(agent_url): - return buyer - - evaluator, _ = records( - { - trust_record_url(ORIGIN): SIGNER_RECORD, - trust_record_url("brand.com"): {"grants": [grant("platform.com")]}, - } - ) - gate = TrustGrantGate( - TrustGrantEvaluator(evaluator, clock=lambda: 1000), - lambda account, agent: TrustGrantClaim("brand.com", "platform.com"), - ) - context = ToolContext( - metadata={ - "adcp.auth_info": AuthInfo( - kind="http_sig", - credential=HttpSigCredential( - kind="http_sig", keyid="kid", agent_url=ORIGIN, verified_at=1000 - ), - ) - } - ) - with ThreadPoolExecutor(max_workers=1) as executor: - handler = PlatformHandler( - Platform(), - executor=executor, - registry=InMemoryTaskRegistry(), - buyer_agent_registry=signing_only_registry(lookup), - trust_grant_gate=gate, - brand_authorization_gate=BrandAuthorizationGate( - resolver=Legacy(), - extract_identity=lambda account, agent: BrandIdentity("brand.com"), - ), - ) - account = await handler._resolve_account(None, context, tool_name="get_products") - assert account.id.startswith("existing") - - -async def test_grant_expiring_during_intermediary_fetch_is_not_authorized(): - now = [1000] - docs = { - trust_record_url(ORIGIN): SIGNER_RECORD, - trust_record_url("brand.com"): { - "grants": [grant("agency.com", valid_until="1970-01-01T00:16:50Z")] - }, - trust_record_url("agency.com"): {"grants": [grant("platform.com")]}, - } - - async def fetch(url, *, allow_private=False): - if url == trust_record_url("agency.com"): - now[0] = 1011 # The first link expired while discovering the second. - return TrustResponse(json.dumps(docs[url]).encode()) - - evaluator = TrustGrantEvaluator(TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0])) - result = await evaluator.check( - agent_origin=ORIGIN, brand_domain="brand.com", account_operator="agency.com" - ) - assert not result.authorized diff --git a/tests/test_identity_discovery_cache_security.py b/tests/test_identity_discovery_cache_security.py deleted file mode 100644 index 0540da771..000000000 --- a/tests/test_identity_discovery_cache_security.py +++ /dev/null @@ -1,484 +0,0 @@ -"""Discovery isolates tenants and bounds unauthenticated fetch pressure.""" - -from __future__ import annotations - -import asyncio -import json -from datetime import datetime, timedelta, timezone -from urllib.parse import urlsplit - -import pytest -from cryptography.hazmat.primitives.asymmetric import ed25519 - -from adcp.signing._bounded_http import ResponseTooLargeError -from adcp.signing.crypto import b64url_encode -from adcp.signing.revocation_fetcher import FetchResult, RevocationListFetchError -from adcp.signing.signer_keys import ResolvedSignerKey -from adcp.signing.trust import ( - TrustRecordAbsentError, - TrustRecordError, - TrustRecordEvaluator, - TrustResponse, -) -from adcp.signing.wba import ( - OriginRevocationChecker, - WbaVerificationError, - public_directory_key, -) - - -def response(origin: str) -> TrustResponse: - return TrustResponse( - json.dumps({"agents": [{"url": origin, "roles": ["adcp:buying"]}]}).encode() - ) - - -async def test_slow_trust_fetch_does_not_block_another_operators_cached_record(): - started, release = asyncio.Event(), asyncio.Event() - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - if urlsplit(url).hostname == "slow.com": - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return response( - "https://buyer.slow.com" - if urlsplit(url).hostname == "slow.com" - else "https://buyer.fast.com" - ) - - records = TrustRecordEvaluator(fetcher=fetch) - assert ( - await records.evaluate("https://buyer.fast.com", role="adcp:buying") - ).operator_domain == "fast.com" - slow = asyncio.create_task(records.evaluate("https://buyer.slow.com")) - try: - assert await asyncio.wait_for(started.wait(), timeout=5) - fast = await asyncio.wait_for(records.evaluate("https://buyer.fast.com"), timeout=1) - assert fast.operator_domain == "fast.com" - finally: - release.set() - assert (await asyncio.wait_for(slow, timeout=5)).operator_domain == "slow.com" - - -async def test_same_operator_trust_fetch_is_shared_and_survives_waiter_cancellation(): - started, release = asyncio.Event(), asyncio.Event() - calls = 0 - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - nonlocal calls - calls += 1 - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return response("https://buyer.agency.com") - - records = TrustRecordEvaluator(fetcher=fetch) - first = asyncio.create_task(records.evaluate("https://buyer.agency.com")) - assert await asyncio.wait_for(started.wait(), timeout=5) - second = asyncio.create_task(records.evaluate("https://other.agency.com")) - await asyncio.sleep(0) - first.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(first, timeout=5) - release.set() - assert (await asyncio.wait_for(second, timeout=5)).operator_domain == "agency.com" - assert calls == 1 - - -@pytest.mark.parametrize("status", [404, 500]) -async def test_negative_trust_cache_expires_within_sixty_seconds(status: int): - now, calls = [1000.0], 0 - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - nonlocal calls - calls += 1 - return TrustResponse(b"", status) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - error = TrustRecordAbsentError if status == 404 else TrustRecordError - for _ in range(3): - with pytest.raises(error): - await records.evaluate("https://buyer.agency.com") - assert calls == 1 - now[0] += 60 - with pytest.raises(error): - await records.evaluate("https://buyer.agency.com") - assert calls == 2 - - -async def test_trust_discovery_bounds_pending_and_negative_records(): - started, release = asyncio.Event(), asyncio.Event() - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return TrustResponse(b"", 404) - - records = TrustRecordEvaluator(fetcher=fetch, max_records=1) - first = asyncio.create_task(records.evaluate("https://one.com")) - assert await asyncio.wait_for(started.wait(), timeout=5) - with pytest.raises(TrustRecordError, match="capacity"): - await records.evaluate("https://two.com") - release.set() - with pytest.raises(TrustRecordAbsentError): - await asyncio.wait_for(first, timeout=5) - with pytest.raises(TrustRecordAbsentError): - await records.evaluate("https://two.com") - assert len(records._failures) == 1 - assert not records._pending - - -async def test_negative_cache_and_eviction_do_not_forget_successful_trust_discovery(): - now = [1000.0] - absent = False - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - origin = ( - "https://buyer.agency.com" - if urlsplit(url).hostname == "agency.com" - else "https://other.com" - ) - return TrustResponse(b"", 404) if absent else response(origin) - - records = TrustRecordEvaluator(fetcher=fetch, max_records=1, clock=lambda: now[0]) - assert (await records.evaluate("https://buyer.agency.com")).operator_domain == "agency.com" - assert (await records.evaluate("https://other.com")).operator_domain == "other.com" - absent = True - now[0] += 60 - for _ in range(2): - with pytest.raises(TrustRecordError) as exc: - await records.evaluate("https://buyer.agency.com") - assert not isinstance(exc.value, TrustRecordAbsentError) - - -async def test_shared_hop_fetch_coalesces_without_sharing_operator_authority(): - started, release = asyncio.Event(), asyncio.Event() - calls = 0 - target = "https://hosting.com/shared.json" - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - nonlocal calls - if url == target: - calls += 1 - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return response("https://buyer.one.com") - return TrustResponse(json.dumps({"authoritative_location": target}).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, max_fetches_per_host=1) - first = asyncio.create_task(records.evaluate("https://buyer.one.com")) - assert await asyncio.wait_for(started.wait(), timeout=5) - second = asyncio.create_task(records.evaluate("https://buyer.two.com")) - await asyncio.sleep(0) - release.set() - assert (await asyncio.wait_for(first, timeout=5)).operator_domain == "one.com" - with pytest.raises(TrustRecordError, match="owner"): - await asyncio.wait_for(second, timeout=5) - assert calls == 1 - assert not records._hosts_inflight - - -async def test_shared_hop_host_limits_other_paths_without_blocking_distinct_host(): - started, release = asyncio.Event(), asyncio.Event() - host_calls = [] - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - if urlsplit(url).hostname == "hosting.com": - host_calls.append(url) - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return TrustResponse(b'{"agents":[]}') - if urlsplit(url).hostname == "fast.com": - return response("https://buyer.fast.com") - path = "one" if urlsplit(url).hostname == "one.com" else "two" - return TrustResponse( - json.dumps({"authoritative_location": f"https://hosting.com/{path}.json"}).encode() - ) - - records = TrustRecordEvaluator(fetcher=fetch, max_fetches_per_host=1) - first = asyncio.create_task(records.evaluate("https://buyer.one.com")) - try: - assert await asyncio.wait_for(started.wait(), timeout=5) - with pytest.raises(TrustRecordError): - await records.evaluate("https://buyer.two.com") - fast = await asyncio.wait_for(records.evaluate("https://buyer.fast.com"), timeout=1) - assert fast.operator_domain == "fast.com" - assert len(host_calls) == 1 - finally: - release.set() - assert (await asyncio.wait_for(first, timeout=5)).operator_domain == "one.com" - assert (await records.evaluate("https://buyer.two.com")).operator_domain == "two.com" - assert len(host_calls) == 2 - - -async def test_hop_transport_cache_cannot_extend_old_authority_freshness(): - now = [1000.0] - calls = 0 - target = "https://hosting.com/shared.json" - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - nonlocal calls - if url == target: - calls += 1 - return TrustResponse(b'{"agents":[]}') - return TrustResponse(json.dumps({"authoritative_location": target}).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - assert (await records.evaluate("https://one.com")).expires_at == 1060 - now[0] = 1050 - assert (await records.evaluate("https://two.com")).expires_at == 1060 - assert calls == 1 - now[0] = 1060 - assert (await records.evaluate("https://two.com")).expires_at == 1110 - assert calls == 2 - - -async def test_hop_host_failure_cooldown_is_scoped_to_original_owner(): - now, calls = [1000.0], 0 - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - nonlocal calls - if urlsplit(url).hostname == "hosting.com": - calls += 1 - if calls == 1: - raise OSError("offline") - return TrustResponse(b'{"agents":[]}') - path = "one" if urlsplit(url).hostname == "one.com" else "two" - return TrustResponse( - json.dumps({"authoritative_location": f"https://hosting.com/{path}.json"}).encode() - ) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - with pytest.raises(TrustRecordError) as exc: - await records.evaluate("https://one.com") - assert not isinstance(exc.value, TrustRecordAbsentError) - assert calls == 1 - assert (await records.evaluate("https://two.com")).operator_domain == "two.com" - assert calls == 2 - now[0] += 60 - assert (await records.evaluate("https://one.com")).operator_domain == "one.com" - assert calls == 3 - - -@pytest.mark.parametrize( - "failure", ["raises", "server_error", "oversized", "stream_oversized", "encoded_body"] -) -async def test_attacker_pointer_cannot_poison_victims_own_trust_fetch(failure: str): - victim_calls = [] - attack_target = "https://victim.com/attacker-selected.json" - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - if urlsplit(url).hostname == "attacker.com": - return TrustResponse(json.dumps({"authoritative_location": attack_target}).encode()) - victim_calls.append(url) - if url == attack_target: - if failure == "raises": - raise OSError("attacker-selected path is offline") - if failure == "server_error": - return TrustResponse(b"", 503) - if failure == "stream_oversized": - raise ResponseTooLargeError(limit=2 * 1024 * 1024, received=2 * 1024 * 1024 + 1) - if failure == "encoded_body": - raise ValueError("encoded security document responses are not accepted") - return TrustResponse(b" " * (2 * 1024 * 1024 + 1)) - return response("https://buyer.victim.com") - - records = TrustRecordEvaluator(fetcher=fetch) - with pytest.raises(TrustRecordError): - await records.evaluate("https://buyer.attacker.com") - victim = await records.evaluate("https://buyer.victim.com", role="adcp:buying") - assert victim.operator_domain == "victim.com" - assert victim_calls == [attack_target, "https://victim.com/.well-known/trust.json"] - assert ("victim.com", "victim.com") not in records._host_failures - if failure in {"oversized", "stream_oversized", "encoded_body"}: - assert not records._host_failures - - -async def test_fifth_shared_host_owner_retries_immediately_after_backpressure(): - release, full = asyncio.Event(), asyncio.Event() - host_calls = [] - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - host = urlsplit(url).hostname - if host == "hosting.com": - host_calls.append(url) - if len(host_calls) == 4: - full.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return TrustResponse(b'{"agents":[]}') - return TrustResponse( - json.dumps({"authoritative_location": f"https://hosting.com/{host}.json"}).encode() - ) - - records = TrustRecordEvaluator(fetcher=fetch, max_fetches_per_host=4) - existing = [ - asyncio.create_task(records.evaluate(f"https://owner-{index}.com")) for index in range(4) - ] - try: - assert await asyncio.wait_for(full.wait(), timeout=5) - with pytest.raises(TrustRecordError, match="capacity"): - await records.evaluate("https://owner-4.com") - assert "owner-4.com" not in records._failures - assert ("owner-4.com", "hosting.com") not in records._host_failures - assert len(host_calls) == 4 - finally: - release.set() - finished = await asyncio.wait_for(asyncio.gather(*existing), timeout=5) - assert {record.operator_domain for record in finished} == { - f"owner-{index}.com" for index in range(4) - } - recovered = await records.evaluate("https://owner-4.com") - assert recovered.operator_domain == "owner-4.com" - assert len(host_calls) == 5 - assert not records._host_totals - assert not records._hosts_inflight - - -async def test_exhausted_global_discovery_capacity_is_not_negative_cached(): - started, release = asyncio.Event(), asyncio.Event() - calls = [] - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - host = urlsplit(url).hostname - calls.append(host) - if host == "first.com": - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return response(f"https://buyer.{host}") - - records = TrustRecordEvaluator(fetcher=fetch, max_records=1) - first = asyncio.create_task(records.evaluate("https://buyer.first.com")) - try: - assert await asyncio.wait_for(started.wait(), timeout=5) - with pytest.raises(TrustRecordError, match="capacity"): - await records.evaluate("https://buyer.second.com") - assert "second.com" not in records._failures - finally: - release.set() - assert (await asyncio.wait_for(first, timeout=5)).operator_domain == "first.com" - recovered = await records.evaluate("https://buyer.second.com") - assert recovered.operator_domain == "second.com" - assert calls == ["first.com", "second.com"] - - -def signed_revocations(private, origin: str, *, revoked: bool = False) -> FetchResult: - jwk = public_directory_key(private) - now = datetime.now(timezone.utc) - payload = { - "version": 1, - "issuer": origin, - "updated": (now - timedelta(seconds=1)).isoformat(), - "next_update": (now + timedelta(minutes=5)).isoformat(), - "revoked_kids": [jwk["kid"]] if revoked else [], - "revoked_jtis": [], - } - protected = b64url_encode( - json.dumps({"alg": "EdDSA", "kid": jwk["kid"], "typ": "adcp-gov-revocation+jws"}).encode() - ) - encoded = b64url_encode(json.dumps(payload).encode()) - signature = b64url_encode(private.sign(f"{protected}.{encoded}".encode())) - return FetchResult(f"{protected}.{encoded}.{signature}", etag=None) - - -async def test_failed_initial_revocations_do_not_consume_permanent_admission(): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(signer_origin, jwk) if keyid == jwk["kid"] else None - - async def fetch(uri, **kwargs): - if "failed" in uri: - raise RevocationListFetchError("not published") - return signed_revocations(private, "https://buyer.legitimate.com") - - checker = OriginRevocationChecker(resolve, fetcher=fetch, max_origins=1) - for index in range(5): - with pytest.raises(WbaVerificationError, match="unavailable"): - await checker(f"https://failed-{index}.agency.com", jwk["kid"]) - assert not checker._checkers - assert len(checker._failures) == 1 - assert not await checker("https://buyer.legitimate.com", jwk["kid"]) - assert len(checker._checkers) == 1 - - -async def test_revocation_discovery_negative_cache_expires_and_preserves_revoked_history(): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - now, calls = [1000.0], 0 - origin = "https://buyer.agency.com" - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(signer_origin, jwk) - - async def fetch(uri, **kwargs): - nonlocal calls - calls += 1 - if calls == 1: - raise RevocationListFetchError("offline") - return signed_revocations(private, origin, revoked=True) - - checker = OriginRevocationChecker(resolve, fetcher=fetch, clock=lambda: now[0]) - with pytest.raises(WbaVerificationError, match="unavailable"): - await checker(origin, jwk["kid"]) - with pytest.raises(WbaVerificationError, match="cooling"): - await checker(origin, jwk["kid"]) - assert calls == 1 - now[0] += 60 - assert await checker(origin, jwk["kid"]) - assert await checker(origin, jwk["kid"]) - assert calls == 2 - assert origin in checker._checkers - assert origin not in checker._failures - - -async def test_operator_revocation_admission_quota_leaves_room_for_other_operators(): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(signer_origin, jwk) - - async def fetch(uri, **kwargs): - return signed_revocations(private, uri.partition("/.well-known")[0]) - - checker = OriginRevocationChecker( - resolve, fetcher=fetch, max_origins=4, max_origins_per_operator=2 - ) - for index in range(2): - assert not await checker(f"https://buyer-{index}.agency.com", jwk["kid"]) - with pytest.raises(WbaVerificationError, match="operator") as exc: - await checker("https://buyer-3.agency.com", jwk["kid"]) - assert exc.value.status == 503 - assert not await checker("https://buyer.other.com", jwk["kid"]) - assert len(checker._checkers) == 3 - - -async def test_revocation_first_discovery_is_shared_and_not_cancelled_by_one_waiter(): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - started, release = asyncio.Event(), asyncio.Event() - calls = 0 - origin = "https://buyer.agency.com" - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(signer_origin, jwk) - - async def fetch(uri, **kwargs): - nonlocal calls - calls += 1 - started.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return signed_revocations(private, origin) - - checker = OriginRevocationChecker(resolve, fetcher=fetch) - first = asyncio.create_task(checker(origin, jwk["kid"])) - assert await asyncio.wait_for(started.wait(), timeout=5) - second = asyncio.create_task(checker(origin, jwk["kid"])) - await asyncio.sleep(0) - first.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(first, timeout=5) - release.set() - assert not await asyncio.wait_for(second, timeout=5) - assert calls == 1 - assert not checker._pending diff --git a/tests/test_identity_history_admission.py b/tests/test_identity_history_admission.py deleted file mode 100644 index 0b5ba2dc7..000000000 --- a/tests/test_identity_history_admission.py +++ /dev/null @@ -1,503 +0,0 @@ -"""Authority-history quotas refuse new admissions without forgetting other tenants.""" - -from __future__ import annotations - -import asyncio -import json -from datetime import datetime, timedelta, timezone -from urllib.parse import urlsplit - -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from adcp.decisioning.registry import BuyerAgent -from adcp.decisioning.trust_grants import TrustGrantClaim, TrustGrantGate -from adcp.decisioning.types import AdcpError -from adcp.signing.crypto import b64url_encode -from adcp.signing.revocation_fetcher import ( - AsyncCachingRevocationChecker, - FetchResult, - RevocationListFreshnessError, -) -from adcp.signing.signer_keys import ResolvedSignerKey -from adcp.signing.trust import ( - GrantAuthorization, - TrustGrantEvaluator, - TrustRecordEvaluator, - TrustRecordNotFoundError, - TrustRecordUnavailableError, - TrustResponse, -) -from adcp.signing.wba import ( - DiscoveredSignerKeys, - OriginRevocationChecker, - WbaUnavailableError, - public_directory_key, - sign_key_directory, -) -from tests.test_wba_authorization_errors import ( - changing_grant_gate, - require_existing_claim, -) - - -@pytest.mark.parametrize("bad", [True, False, 0, -1, 1.5]) -@pytest.mark.parametrize( - "factory,parameter", - [ - (TrustRecordEvaluator, "max_history_records"), - (TrustRecordEvaluator, "max_records"), - (TrustRecordEvaluator, "max_fetches_per_host"), - (DiscoveredSignerKeys, "max_origins"), - (DiscoveredSignerKeys, "max_origins_per_operator"), - (OriginRevocationChecker, "max_facts_per_origin"), - (OriginRevocationChecker, "max_origins"), - (OriginRevocationChecker, "max_origins_per_operator"), - (OriginRevocationChecker, "max_facts_per_operator"), - (OriginRevocationChecker, "max_history_facts"), - (TrustGrantGate, "max_denials"), - (TrustGrantGate, "max_claims_per_operator"), - ], -) -def test_history_limits_require_positive_integers(factory, parameter, bad): - args = [] - if factory is OriginRevocationChecker: - args = [lambda keyid, **kwargs: None] - if factory is TrustGrantGate: - args = [None, None] - with pytest.raises(ValueError): - factory(*args, **{parameter: bad}) - - -async def test_private_tenant_flood_cannot_evict_trust_publication_or_removal(): - now, modes, calls = [1000.0], {}, [] - - async def fetch(url, **kwargs): - owner = urlsplit(url).hostname - calls.append(owner) - if modes.get(owner) == "removed": - return TrustResponse(b"", 404) - if modes.get(owner) == "offline": - raise OSError("offline") - return TrustResponse(b'{"agents":[]}') - - records = TrustRecordEvaluator( - fetcher=fetch, clock=lambda: now[0], max_records=1, max_history_records=3 - ) - victim = "victim.vercel.app" - await records.evaluate(victim) - await records.evaluate("attacker-a.vercel.app") - await records.evaluate("attacker-b.vercel.app") - for index in range(10): - owner = f"overflow-{index}.vercel.app" - with pytest.raises(TrustRecordUnavailableError, match="history admission"): - await records.evaluate(owner) - assert owner not in records._failures - assert owner not in calls - assert len(records._seen_200) == len(records._last_policy) == 3 - assert victim in records._last_policy - modes[victim], now[0] = "removed", 1061 - with pytest.raises(TrustRecordNotFoundError): - await records.evaluate(victim) - modes[victim], now[0] = "offline", 1122 - with pytest.raises(TrustRecordNotFoundError): - await records.evaluate(victim, allow_stale_on_outage=True) - assert victim in records._seen_200 - assert len(records._history_owners) == 3 - - -async def test_failed_and_cancelled_pending_trust_reads_release_history_reservations(): - entered, release = asyncio.Event(), asyncio.Event() - - async def fetch(url, **kwargs): - if urlsplit(url).hostname == "attacker.vercel.app": - entered.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - raise OSError("no authoritative response") - return TrustResponse(b'{"agents":[]}') - - records = TrustRecordEvaluator(fetcher=fetch, max_history_records=1) - first = asyncio.create_task(records.evaluate("attacker.vercel.app")) - try: - assert await asyncio.wait_for(entered.wait(), timeout=5) - shared = records._pending["attacker.vercel.app"] - with pytest.raises(TrustRecordUnavailableError, match="history admission"): - await records.evaluate("victim.vercel.app") - first.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(first, timeout=5) - assert len(records._history_owners) == 1 - release.set() - with pytest.raises(TrustRecordUnavailableError): - await asyncio.wait_for(asyncio.shield(shared), timeout=5) - finally: - release.set() - await asyncio.wait_for(asyncio.gather(first, return_exceptions=True), timeout=5) - assert not records._history_owners - assert (await records.evaluate("victim.vercel.app")).operator_domain == "victim.vercel.app" - - -async def test_previous_absent_owner_keeps_admission_without_authorizing_stale_absence(): - calls = [] - now, offline = [1000.0], [False] - - async def fetch(url, **kwargs): - owner = urlsplit(url).hostname - calls.append(owner) - if offline[0] and owner == "unknown.vercel.app": - raise OSError("unavailable") - return ( - TrustResponse(b"", 404) - if owner == "unknown.vercel.app" - else TrustResponse(b'{"agents":[]}') - ) - - records = TrustRecordEvaluator(fetcher=fetch, max_history_records=2, clock=lambda: now[0]) - from adcp.signing.trust import TrustRecordAbsentError - - with pytest.raises(TrustRecordAbsentError): - await records.evaluate("unknown.vercel.app") - assert records._history_owners == {"unknown.vercel.app"} - await records.evaluate("victim.vercel.app") - with pytest.raises(TrustRecordUnavailableError, match="history admission"): - await records.evaluate("new.vercel.app") - now[0] = 1061 - with pytest.raises(TrustRecordAbsentError): - await records.evaluate("unknown.vercel.app") - offline[0], now[0] = True, 1122 - with pytest.raises(TrustRecordUnavailableError): - await records.evaluate("unknown.vercel.app", allow_stale_on_outage=True) - assert "unknown.vercel.app" not in records._seen_200 - assert "unknown.vercel.app" not in records._last_policy - assert len(records._history_owners) == 2 - assert calls == [ - "unknown.vercel.app", - "victim.vercel.app", - "unknown.vercel.app", - "unknown.vercel.app", - ] - - -async def test_previously_used_legacy_brand_can_refresh_after_history_fills(): - now, offline, legacy_calls = [1000.0], [False], [] - origin = "https://buyer.platform.com" - - async def fetch(url, **kwargs): - if urlsplit(url).hostname == "platform.com": - return TrustResponse( - json.dumps({"agents": [{"url": origin, "roles": ["adcp:buying"]}]}).encode() - ) - if offline[0]: - raise OSError("brand record unavailable") - return TrustResponse(b"", 404) - - async def legacy(request): - legacy_calls.append(request.brand_domain) - return True - - records = TrustRecordEvaluator(fetcher=fetch, max_history_records=2, clock=lambda: now[0]) - evaluator = TrustGrantEvaluator(records, legacy_authorization=legacy) - request = { - "agent_origin": origin, - "brand_domain": "brand.com", - "account_operator": "platform.com", - } - assert (await evaluator.check(**request)).authorized - assert records._history_owners == {"platform.com", "brand.com"} - with pytest.raises(TrustRecordUnavailableError, match="history admission"): - await records.evaluate("attacker.vercel.app") - now[0] = 1061 - assert (await evaluator.check(**request)).authorized - offline[0], now[0] = True, 1122 - result = await evaluator.check(**request, allow_stale_on_outage=True) - assert not result.authorized - assert result.error_code == "SERVICE_UNAVAILABLE" - assert legacy_calls == ["brand.com", "brand.com"] - - -async def test_private_tenant_directory_flood_preserves_victims_signed_snapshot(): - private = Ed25519PrivateKey.generate() - kid = public_directory_key(private)["kid"] - now, offline, calls = [1000.0], set(), [] - - async def fetch(url, **kwargs): - origin = "https://" + urlsplit(url).hostname - calls.append(origin) - if origin in offline: - raise OSError("offline") - return sign_key_directory(agent_origin=origin, private_keys=[private], created=int(now[0])) - - keys = DiscoveredSignerKeys(fetcher=fetch, max_origins=3, clock=lambda: now[0]) - victim = "https://victim.vercel.app" - for origin in (victim, "https://attacker-a.vercel.app", "https://attacker-b.vercel.app"): - assert await keys(kid, signer_origin=origin) is not None - old_snapshot = keys._cache[victim].response - for index in range(10): - origin = f"https://overflow-{index}.vercel.app" - with pytest.raises(WbaUnavailableError, match="capacity"): - await keys(kid, signer_origin=origin) - assert origin not in calls - assert origin not in keys._failed_discoveries - assert await keys(kid, signer_origin=victim) is not None - offline.add(victim) - now[0] = 1301 - with pytest.raises(WbaUnavailableError): - await keys(kid, signer_origin=victim) - assert keys._cache[victim].response is old_snapshot - assert len(keys._cache) == 3 - - -async def test_directory_operator_pending_quota_does_not_block_other_operators(): - private = Ed25519PrivateKey.generate() - kid = public_directory_key(private)["kid"] - entered, release = asyncio.Event(), asyncio.Event() - attacker = "https://one.attacker.vercel.app" - - async def fetch(url, **kwargs): - origin = "https://" + urlsplit(url).hostname - if origin == attacker: - entered.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - raise OSError("offline") - return sign_key_directory(agent_origin=origin, private_keys=[private], created=1000) - - keys = DiscoveredSignerKeys( - fetcher=fetch, max_origins=2, max_origins_per_operator=1, clock=lambda: 1000 - ) - first = asyncio.create_task(keys(kid, signer_origin=attacker)) - try: - assert await asyncio.wait_for(entered.wait(), timeout=5) - with pytest.raises(WbaUnavailableError, match="operator"): - await keys(kid, signer_origin="https://two.attacker.vercel.app") - assert await keys(kid, signer_origin="https://victim.vercel.app") is not None - finally: - release.set() - with pytest.raises(WbaUnavailableError): - await asyncio.wait_for(first, timeout=5) - assert await keys(kid, signer_origin="https://two.attacker.vercel.app") is not None - - -@pytest.mark.parametrize("global_overflow", [False, True]) -async def test_claim_overflow_refuses_only_new_claim_and_preserves_victim_continuity( - global_overflow, -): - original, mode, now = changing_grant_gate(initial="allowed") - gate = TrustGrantGate( - original.evaluator, - original.extract_claim, - max_denials=3 if global_overflow else 8, - max_claims_per_operator=2, - ) - await require_existing_claim(gate) - for index in range(2): - attacker = BuyerAgent( - agent_url=( - f"https://attacker-{index}.vercel.app" - if global_overflow - else "https://buyer.attacker.com" - ), - display_name="Attacker", - status="active", - ) - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await require_existing_claim( - gate, TrustGrantClaim("brand.com", "platform.com", brand_id=str(index)), attacker - ) - attacker = BuyerAgent( - agent_url="https://new.vercel.app" if global_overflow else "https://buyer.attacker.com", - display_name="Attacker", - status="active", - ) - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await require_existing_claim( - gate, - TrustGrantClaim("other-brand.com", "other-account.com", brand_id="overflow"), - attacker, - ) - assert len(gate._history.claims) == 3 - mode[0], now[0] = "offline", 1061 - await require_existing_claim(gate) - assert len(gate._history.positives) == 1 - assert len(gate._history.denials) == 2 - - -async def test_shared_claim_cancellation_cannot_release_another_waiters_slot(): - entered, release = asyncio.Event(), asyncio.Event() - - class Evaluator: - def current_time(self): - return 1000 - - async def check(self, **kwargs): - entered.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return GrantAuthorization(True, expires_at=1060, outage_expires_at=1300) - - gate = TrustGrantGate( - Evaluator(), lambda account, agent: account.metadata["claim"], max_denials=1 - ) - first = asyncio.create_task(require_existing_claim(gate)) - second = None - try: - assert await asyncio.wait_for(entered.wait(), timeout=5) - second = asyncio.create_task(require_existing_claim(gate)) - await asyncio.sleep(0) - first.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(first, timeout=5) - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await require_existing_claim( - gate, TrustGrantClaim("brand.com", "platform.com", brand_id="other") - ) - second.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(second, timeout=5) - assert not gate._history.claims - assert not gate._history.operator_counts - release.set() - await require_existing_claim( - gate, TrustGrantClaim("brand.com", "platform.com", brand_id="other") - ) - finally: - release.set() - await asyncio.wait_for( - asyncio.gather( - *[task for task in (first, second) if task is not None], return_exceptions=True - ), - timeout=5, - ) - - -def signed_list(private, origin, now, revoked): - kid = public_directory_key(private)["kid"] - payload = { - "version": 1, - "issuer": origin, - "updated": now.isoformat(), - "next_update": (now + timedelta(seconds=60)).isoformat(), - "revoked_kids": revoked, - "revoked_jtis": [], - } - protected = b64url_encode( - json.dumps({"alg": "EdDSA", "kid": kid, "typ": "adcp-gov-revocation+jws"}).encode() - ) - body = b64url_encode(json.dumps(payload).encode()) - signature = b64url_encode(private.sign(f"{protected}.{body}".encode())) - return FetchResult(f"{protected}.{body}.{signature}", etag=None) - - -@pytest.mark.parametrize( - "limit", ["max_facts_per_origin", "max_facts_per_operator", "max_history_facts"] -) -@pytest.mark.parametrize("initial_overflow", [False, True]) -async def test_authenticated_revocation_overflow_permanently_closes_only_offending_origin( - limit, initial_overflow -): - private = Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - now, lists, calls = [1000.0], {}, [] - start = datetime(2026, 1, 1, tzinfo=timezone.utc) - victim, attacker = "https://victim.com", "https://buyer.attacker.com" - lists[attacker] = ["old", "new"] if initial_overflow else ["old"] - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(signer_origin, jwk) - - async def fetch(uri, **kwargs): - origin = uri.partition("/.well-known/")[0] - calls.append(origin) - return signed_list( - private, origin, start + timedelta(seconds=now[0]), lists.get(origin, []) - ) - - checker = OriginRevocationChecker( - resolve, - fetcher=fetch, - clock=lambda: now[0], - wall_clock=lambda: start + timedelta(seconds=now[0]), - **{limit: 1}, - ) - assert not await checker(victim, jwk["kid"]) - if not initial_overflow: - assert await checker(attacker, "old") - lists[attacker], now[0] = ["new"], 1061 - with pytest.raises(WbaUnavailableError): - await checker(attacker, "new") - assert checker._checkers[attacker]._history_overflowed - lists[attacker], now[0] = [], 1122 - old_calls = len(calls) - with pytest.raises(WbaUnavailableError): - await checker(attacker, "new") - assert len(calls) == old_calls - assert not await checker(victim, jwk["kid"]) - assert checker._history_fact_count <= 1 - if not initial_overflow: - assert checker._checkers[attacker]._current_list.is_revoked("old") - - -@pytest.mark.parametrize("initial_list", [False, True]) -@pytest.mark.parametrize("hook_raises", [False, True]) -async def test_queued_revocation_calls_cannot_reuse_grace_after_history_refusal( - initial_list, hook_raises -): - private = Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - start, now = datetime(2026, 1, 1, tzinfo=timezone.utc), [0.0] - entered, release = asyncio.Event(), asyncio.Event() - calls, admissions = [], [] - - async def resolve(keyid): - return jwk - - async def fetch(uri, **kwargs): - calls.append(uri) - if len(calls) > (1 if initial_list else 0): - entered.set() - assert await asyncio.wait_for(release.wait(), timeout=5) - return signed_list( - private, "https://attacker.com", start + timedelta(seconds=now[0]), ["new"] - ) - - def admit(record): - admissions.append(record) - if initial_list and len(admissions) == 1: - return True - if hook_raises: - raise RuntimeError("persistence unavailable") - return False - - checker = AsyncCachingRevocationChecker.from_issuer_origin( - "https://attacker.com", - jwks_resolver=resolve, - fetcher=fetch, - clock=lambda: now[0], - wall_clock=lambda: start + timedelta(seconds=now[0]), - history_admission=admit, - ) - if initial_list: - assert not await checker(jwk["kid"]) - now[0] = 61 - first = asyncio.create_task(checker(jwk["kid"])) - second = None - try: - assert await asyncio.wait_for(entered.wait(), timeout=5) - if initial_list: - # A second request inside grace joins the refresh lock once its - # own cooldown elapsed, rather than taking pre-observation grace. - now[0] = 122 - second = asyncio.create_task(checker(jwk["kid"])) - await asyncio.sleep(0) - release.set() - for task in (first, second): - with pytest.raises(RevocationListFreshnessError): - await asyncio.wait_for(task, timeout=5) - assert len(calls) == (2 if initial_list else 1) - assert checker.has_verified_list - finally: - release.set() - await asyncio.wait_for( - asyncio.gather( - *[task for task in (first, second) if task is not None], return_exceptions=True - ), - timeout=5, - ) diff --git a/tests/test_identity_protocol_regressions.py b/tests/test_identity_protocol_regressions.py deleted file mode 100644 index f5e5ffaa0..000000000 --- a/tests/test_identity_protocol_regressions.py +++ /dev/null @@ -1,896 +0,0 @@ -"""Identity migration preserves legacy authority without allowing WBA downgrade.""" - -from __future__ import annotations - -import asyncio -import json -from concurrent.futures import ThreadPoolExecutor -from types import SimpleNamespace - -import httpx -import pytest - -from adcp.decisioning import ( - ApiKeyCredential, - AuthInfo, - BuyerAgent, - DecisioningCapabilities, - DecisioningPlatform, - HttpSigCredential, - InMemoryTaskRegistry, - SingletonAccounts, - bearer_only_registry, - signing_only_registry, -) -from adcp.decisioning.handler import PlatformHandler, _resolve_buyer_agent -from adcp.decisioning.trust_grants import TrustGrantClaim, TrustGrantGate -from adcp.decisioning.types import Account, AdcpError -from adcp.server import ToolContext -from adcp.signing import ( - TrustGrantEvaluator, - TrustRecordEvaluator, - TrustRecordMatchError, - TrustRecordUnavailableError, - TrustResponse, - trust_record_url, -) -from adcp.signing.trust import fetch_trust_record - -ORIGIN = "https://buyer.platform.com" -LEGACY_PROFILE = "adcp/request-signing/v1" - - -def make_evaluator(documents, legacy_calls, fetch_calls): - async def fetch(url, *, allow_private=False): - fetch_calls.append(url) - document = documents.get(url) - if isinstance(document, Exception): - raise document - return ( - TrustResponse(json.dumps(document).encode()) - if document is not None - else TrustResponse(b"", 404) - ) - - async def legacy(request): - legacy_calls.append(request) - return True - - records = TrustRecordEvaluator(fetcher=fetch) - return TrustGrantEvaluator(records, legacy_authorization=legacy) - - -async def test_legacy_buyer_needs_no_new_operator_record_before_brand_publication(): - legacy_calls, fetch_calls = [], [] - evaluator = make_evaluator({}, legacy_calls, fetch_calls) - result = await evaluator.check( - agent_origin=ORIGIN, - brand_domain="brand.com", - account_operator="platform.com", - signing_profile=LEGACY_PROFILE, - ) - assert result.authorized and result.legacy_fallback_allowed - assert len(legacy_calls) == 1 - assert fetch_calls == [trust_record_url("brand.com")] - - -async def test_wba_missing_operator_record_never_reaches_legacy_authority(): - legacy_calls, fetch_calls = [], [] - evaluator = make_evaluator({}, legacy_calls, fetch_calls) - result = await evaluator.check( - agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" - ) - assert not result.authorized - assert result.reason == "signer_trust_record_unavailable" - assert not legacy_calls - assert fetch_calls == [trust_record_url(ORIGIN)] - - -@pytest.mark.parametrize("document", [{"agents": []}, RuntimeError("network unavailable")]) -async def test_legacy_brand_publication_or_fetch_failure_does_not_fall_back(document): - legacy_calls, fetch_calls = [], [] - evaluator = make_evaluator({trust_record_url("brand.com"): document}, legacy_calls, fetch_calls) - result = await evaluator.check( - agent_origin=ORIGIN, - brand_domain="brand.com", - account_operator="platform.com", - signing_profile=LEGACY_PROFILE, - ) - assert not result.authorized - assert not legacy_calls - - -@pytest.mark.parametrize("trusted_profile", [LEGACY_PROFILE, "web-bot-auth"]) -async def test_dispatch_uses_trusted_profile_before_sync_accounts_upsert(trusted_profile): - touched, legacy_calls, fetch_calls = [], [], [] - - class Accounts(SingletonAccounts): - def upsert_request(self, request, ctx=None): - touched.append("upsert") - return {"accounts": []} - - class Platform(DecisioningPlatform): - capabilities = DecisioningCapabilities() - accounts = Accounts(account_id="existing") - - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") - - async def lookup(agent_url): - return buyer - - gate = TrustGrantGate( - make_evaluator({}, legacy_calls, fetch_calls), - lambda account, agent: TrustGrantClaim("brand.com", "platform.com"), - ) - auth = AuthInfo( - kind="http_sig", - credential=HttpSigCredential( - kind="http_sig", keyid="kid", agent_url=ORIGIN, verified_at=1000 - ), - extra={"signing_profile": trusted_profile, "principal_id": "approved-principal"}, - ) - # Caller-supplied fields must never select the compatibility path. - declaration = { - "brand": {"domain": "brand.com"}, - "operator": "platform.com", - "signing_profile": LEGACY_PROFILE if trusted_profile == "web-bot-auth" else "web-bot-auth", - } - with ThreadPoolExecutor(max_workers=1) as executor: - handler = PlatformHandler( - Platform(), - executor=executor, - registry=InMemoryTaskRegistry(), - buyer_agent_registry=signing_only_registry(lookup), - trust_grant_gate=gate, - ) - context = ToolContext(metadata={"adcp.auth_info": auth}) - if trusted_profile == "web-bot-auth": - with pytest.raises(AdcpError) as error: - await handler.sync_accounts(SimpleNamespace(accounts=[declaration]), context) - assert error.value.code == "PERMISSION_DENIED" - assert not error.value.details - assert not touched and not legacy_calls - else: - await handler.sync_accounts(SimpleNamespace(accounts=[declaration]), context) - assert touched == ["upsert"] - assert len(legacy_calls) == 1 - - -@pytest.mark.parametrize("granted_scope", ["adcp:media_buying", "adcp:creative_generation"]) -async def test_operation_extractor_uses_creative_authority_not_media_buying(granted_scope): - legacy_calls, fetch_calls, operations = [], [], [] - evaluator = make_evaluator( - { - trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, - trust_record_url("brand.com"): { - "grants": [{"grantee": "platform.com", "scopes": [granted_scope]}] - }, - }, - legacy_calls, - fetch_calls, - ) - - async def operation_claim(account, agent, operation): - operations.append(operation) - return TrustGrantClaim("brand.com", "platform.com", scope="adcp:creative_generation") - - gate = TrustGrantGate( - evaluator, - lambda account, agent: TrustGrantClaim("brand.com", "platform.com"), - extract_operation_claim=operation_claim, - ) - account = Account(id="existing") - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") - if granted_scope == "adcp:creative_generation": - await gate.require_account(account, buyer, operation="build_creative") - else: - with pytest.raises(AdcpError) as error: - await gate.require_account(account, buyer, operation="build_creative") - assert error.value.details["reason"] == "grant_absent" - assert operations == ["build_creative"] - assert not legacy_calls - - -async def test_non_buying_operation_cannot_inherit_default_media_buying_scope(): - legacy_calls, fetch_calls = [], [] - gate = TrustGrantGate( - make_evaluator({}, legacy_calls, fetch_calls), - lambda account, agent: TrustGrantClaim("brand.com", "platform.com"), - ) - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") - with pytest.raises(AdcpError, match="explicit grant scope"): - await gate.require_account(Account(id="existing"), buyer, operation="build_creative") - assert not fetch_calls and not legacy_calls - - -@pytest.mark.parametrize("principal", [None, "approved-principal"]) -async def test_unbound_wba_cannot_inherit_legacy_registry_origin(principal): - looked_up = [] - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Existing legacy buyer", status="active") - - async def lookup(agent_url): - looked_up.append(agent_url) - return buyer - - auth = AuthInfo( - kind="http_sig", - credential=HttpSigCredential( - kind="http_sig", keyid="new-directory-key", agent_url=ORIGIN, verified_at=1000 - ), - extra={"signing_profile": "web-bot-auth", "principal_id": principal}, - ) - registry = signing_only_registry(lookup) - if principal is None: - with pytest.raises(AdcpError) as error: - await _resolve_buyer_agent(registry, auth) - assert error.value.code == "PERMISSION_DENIED" - assert not error.value.details - assert not looked_up - else: - assert await _resolve_buyer_agent(registry, auth) is buyer - assert looked_up == [ORIGIN] - - -async def test_explicit_bearer_identity_keeps_its_independent_registry_authority(): - buyer = BuyerAgent(agent_url=ORIGIN, display_name="Approved bearer buyer", status="active") - credential = ApiKeyCredential(kind="api_key", key_id="approved-api-key") - looked_up = [] - - async def lookup(verified_credential): - looked_up.append(verified_credential) - return buyer - - auth = AuthInfo( - kind="api_key", - credential=credential, - extra={"signing_profile": "web-bot-auth", "principal_id": None}, - ) - assert await _resolve_buyer_agent(bearer_only_registry(lookup), auth) is buyer - assert looked_up == [credential] - - -@pytest.mark.parametrize("policy_delay", [0, 49, 50, 51]) -async def test_discretionary_authority_cannot_outlive_required_operator_membership(policy_delay): - now = [1000.0] - - async def fetch(url, *, allow_private=False): - if url == trust_record_url(ORIGIN): - now[0] += 10 - document = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - else: - # The brand's record expires later than the required signer record. - document = {"grants": []} - return TrustResponse(json.dumps(document).encode()) - - async def seller(request): - now[0] += policy_delay - return True - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - result = await TrustGrantEvaluator(records, seller_authorization=seller).check( - agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" - ) - assert (await records.evaluate("brand.com")).expires_at == 1070 - if policy_delay < 50: - assert result.authorized - assert result.expires_at == 1060 - else: - assert not result.authorized - assert result.reason == "trust_record_unavailable" - assert result.error_code == "SERVICE_UNAVAILABLE" - - -@pytest.mark.parametrize("signing_profile", ["web-bot-auth", LEGACY_PROFILE]) -@pytest.mark.parametrize("policy_delay", [0, 60, 61]) -async def test_legacy_fallback_retains_required_wba_membership_expiry( - signing_profile, policy_delay -): - now = [1000.0] - fetched = [] - - async def fetch(url, *, allow_private=False): - fetched.append(url) - if url == trust_record_url(ORIGIN): - return TrustResponse( - json.dumps({"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}).encode() - ) - return TrustResponse(b"", 404) - - async def legacy(request): - now[0] += policy_delay - return True - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - result = await TrustGrantEvaluator(records, legacy_authorization=legacy).check( - agent_origin=ORIGIN, - brand_domain="brand.com", - account_operator="platform.com", - signing_profile=signing_profile, - ) - if signing_profile == LEGACY_PROFILE: - assert result.authorized - assert result.expires_at is None - assert fetched == [trust_record_url("brand.com")] - elif policy_delay < 60: - assert result.authorized - assert result.expires_at == 1060 - else: - assert not result.authorized - assert result.reason == "signer_trust_record_unavailable" - assert result.error_code == "SERVICE_UNAVAILABLE" - - -@pytest.mark.parametrize( - "claim_overrides,grant_overrides,authorized", - [ - ({}, {}, True), - ({"account_operator": "platform.com"}, {}, True), - ({"account_operator": "agency.com"}, {}, False), - ({"brand_id": "other"}, {}, False), - ({"countries": ["CA"]}, {}, False), - ({"scope": "adcp:creative_generation"}, {}, False), - ({}, {"agents": [{"url": "https://different.platform.com"}]}, False), - ({}, {"valid_until": "1970-01-01T00:16:40Z"}, False), - ({}, {"valid_from": "1970-01-01T00:18:20Z"}, False), - ], -) -async def test_self_operated_brand_can_grant_hosted_signer_directly( - claim_overrides, grant_overrides, authorized -): - fetched = [] - grant = { - "grantee": "platform.com", - "scopes": ["adcp:media_buying"], - "agents": [{"url": ORIGIN + "/mcp"}], - "countries": ["US"], - "profiles": {"adcp": {"brands": ["brand_1"]}}, - "valid_until": "1970-01-01T00:18:20Z", - **grant_overrides, - } - documents = { - trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, - trust_record_url("brand.com"): {"grants": [grant], "exclusive_grants": True}, - } - - async def fetch(url, *, allow_private=False): - fetched.append(url) - document = documents.get(url) - return ( - TrustResponse(json.dumps(document).encode()) - if document is not None - else TrustResponse(b"", 404) - ) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: 1000) - result = await TrustGrantEvaluator(records).check( - **{ - "agent_origin": ORIGIN, - "brand_domain": "brand.com", - "account_operator": "brand.com", - "brand_id": "brand_1", - "countries": ["US"], - **claim_overrides, - } - ) - assert result.authorized is authorized - if authorized: - assert result.expires_at == 1060 - assert fetched == [trust_record_url(ORIGIN), trust_record_url("brand.com")] - else: - assert result.error_code == "PERMISSION_DENIED" - assert result.include_grant_absent_details - - -@pytest.mark.parametrize( - "record_url,document,expected_code,expected_details", - [ - (trust_record_url(ORIGIN), {"agents": []}, "PERMISSION_DENIED", {}), - (trust_record_url(ORIGIN), None, "PERMISSION_DENIED", {}), - (trust_record_url(ORIGIN), RuntimeError("offline"), "SERVICE_UNAVAILABLE", {}), - (trust_record_url(ORIGIN), TrustResponse(b"", 503), "SERVICE_UNAVAILABLE", {}), - (trust_record_url("brand.com"), RuntimeError("offline"), "SERVICE_UNAVAILABLE", {}), - (trust_record_url("brand.com"), {"unexpected": True}, "PERMISSION_DENIED", {}), - ( - trust_record_url("brand.com"), - {"grants": []}, - "PERMISSION_DENIED", - {"scope": "agent", "reason": "grant_absent"}, - ), - (trust_record_url("agency.com"), RuntimeError("offline"), "SERVICE_UNAVAILABLE", {}), - ], -) -async def test_grant_failures_distinguish_trust_outages_and_authorization( - record_url, document, expected_code, expected_details -): - documents = { - trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, - trust_record_url("brand.com"): { - "grants": [{"grantee": "agency.com", "scopes": ["adcp:media_buying"]}] - }, - trust_record_url("agency.com"): { - "grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}] - }, - record_url: document, - } - - async def fetch(url, *, allow_private=False): - value = documents[url] - if isinstance(value, Exception): - raise value - if isinstance(value, TrustResponse): - return value - return ( - TrustResponse(json.dumps(value).encode()) - if value is not None - else TrustResponse(b"", 404) - ) - - result = await TrustGrantEvaluator(TrustRecordEvaluator(fetcher=fetch)).check( - agent_origin=ORIGIN, brand_domain="brand.com", account_operator="agency.com" - ) - assert not result.authorized - with pytest.raises(AdcpError) as error: - result.require() - assert error.value.code == expected_code - assert error.value.details == expected_details - assert error.value.recovery == ( - "transient" if expected_code == "SERVICE_UNAVAILABLE" else "correctable" - ) - - -@pytest.mark.parametrize( - "overrides", - [ - {"agent_origin": "https://127.0.0.1"}, - {"agent_origin": "https://co.uk"}, - {"brand_domain": "co.uk"}, - {"account_operator": "127.0.0.1"}, - ], -) -async def test_undefined_operator_derivation_is_not_an_uncovered_grant(overrides): - async def fetch(url, *, allow_private=False): - pytest.fail("An undefined registrable domain must not trigger trust discovery") - - result = await TrustGrantEvaluator(TrustRecordEvaluator(fetcher=fetch)).check( - **{ - "agent_origin": ORIGIN, - "brand_domain": "brand.com", - "account_operator": "platform.com", - **overrides, - } - ) - assert not result.authorized - assert result.error_code == "PERMISSION_DENIED" - with pytest.raises(AdcpError) as error: - result.require() - assert error.value.code == "PERMISSION_DENIED" - assert not error.value.details - - -@pytest.mark.parametrize("when,allowed", [(1061, True), (1299.999, True), (1300, False)]) -async def test_public_record_outage_grace_is_bounded_and_never_fresh(when, allowed): - now, offline = [1000.0], [False] - - async def fetch(url, *, allow_private=False): - if offline[0]: - raise OSError("offline") - return TrustResponse( - json.dumps({"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}).encode() - ) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - initial = await records.evaluate(ORIGIN, role="adcp:buying") - assert records.polling_interval_seconds == 60 - assert not initial.stale_on_outage - offline[0], now[0] = True, when - with pytest.raises(TrustRecordUnavailableError): - await records.evaluate(ORIGIN, role="adcp:buying") - if allowed: - cached = await records.evaluate(ORIGIN, role="adcp:buying", allow_stale_on_outage=True) - assert cached.stale_on_outage - assert cached.expires_at == 1060 - assert cached.revision == initial.revision - await records.validate_snapshots((cached,), origin_or_domain=ORIGIN, role="adcp:buying") - else: - with pytest.raises(TrustRecordUnavailableError, match="grace"): - await records.evaluate(ORIGIN, role="adcp:buying", allow_stale_on_outage=True) - - -async def test_initial_outage_cannot_create_record_authority(): - async def fetch(url, *, allow_private=False): - raise OSError("offline before first read") - - records = TrustRecordEvaluator(fetcher=fetch) - with pytest.raises(TrustRecordUnavailableError, match="no prior"): - await records.evaluate(ORIGIN, role="adcp:buying", allow_stale_on_outage=True) - - -async def test_other_origin_negative_read_survives_global_capacity_and_age(): - now, mode = [1000.0], ["listed"] - started, release = asyncio.Event(), asyncio.Event() - other_origin = "https://other.platform.com" - - async def fetch(url, *, allow_private=False): - if url == trust_record_url("busy.com"): - started.set() - await asyncio.wait_for(release.wait(), timeout=5) - return TrustResponse(json.dumps({"agents": []}).encode()) - agents = [other_origin, ORIGIN] if mode[0] == "listed" else [other_origin] - return TrustResponse( - json.dumps( - {"agents": [{"url": origin, "roles": ["adcp:buying"]} for origin in agents]} - ).encode() - ) - - records = TrustRecordEvaluator(fetcher=fetch, max_records=1, clock=lambda: now[0]) - await records.evaluate(ORIGIN, role="adcp:buying") - mode[0], now[0] = "delisted", 1061 - await records.evaluate(other_origin, role="adcp:buying") - now[0] = 2000 - pending = asyncio.create_task(records.evaluate("busy.com")) - await asyncio.wait_for(started.wait(), timeout=5) - try: - for allow_stale in (False, True): - with pytest.raises(TrustRecordMatchError): - await records.evaluate( - ORIGIN, role="adcp:buying", allow_stale_on_outage=allow_stale - ) - finally: - release.set() - await asyncio.wait_for(pending, timeout=5) - - -@pytest.mark.parametrize("grantor", ["brand.com", "agency.com"]) -@pytest.mark.parametrize("status", [404, 410]) -async def test_removed_grantor_stays_grant_absent_during_later_outage(grantor, status): - now, mode, legacy_calls = [1000.0], ["published"], [] - - async def fetch(url, *, allow_private=False): - if url == trust_record_url(grantor) and mode[0] != "published": - if mode[0] == "offline": - raise OSError("later outage") - return TrustResponse(b"", status) - document = ( - {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - if url == trust_record_url(ORIGIN) - else { - "grants": [ - { - "grantee": ( - "agency.com" if url == trust_record_url("brand.com") else "platform.com" - ), - "scopes": ["adcp:media_buying"], - } - ] - } - ) - return TrustResponse(json.dumps(document).encode()) - - async def legacy(request): - legacy_calls.append(request) - return True - - evaluator = TrustGrantEvaluator( - TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]), legacy_authorization=legacy - ) - claim = {"agent_origin": ORIGIN, "brand_domain": "brand.com", "account_operator": "agency.com"} - assert (await evaluator.check(**claim)).authorized - for phase, when in (("removed", 1061), ("offline", 1122)): - mode[0], now[0] = phase, when - result = await evaluator.check(**claim, allow_stale_on_outage=True) - assert not result.authorized - assert result.error_code == "PERMISSION_DENIED" - assert result.include_grant_absent_details - assert result.reason == "grant_absent" - assert not legacy_calls - - -@pytest.mark.parametrize("grant_expiry", [None, 1200]) -async def test_grant_grace_checks_all_chain_records_and_hard_validity(grant_expiry): - now, offline = [1000.0], [False] - - async def fetch(url, *, allow_private=False): - if offline[0]: - raise OSError("offline") - now[0] += 10 - if url == trust_record_url(ORIGIN): - document = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - else: - grant = { - "grantee": "agency.com" if url == trust_record_url("brand.com") else "platform.com", - "scopes": ["adcp:media_buying"], - } - if grant_expiry is not None: - grant["valid_until"] = "1970-01-01T00:20:00Z" - document = {"grants": [grant]} - return TrustResponse(json.dumps(document).encode()) - - evaluator = TrustGrantEvaluator(TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0])) - claim = {"agent_origin": ORIGIN, "brand_domain": "brand.com", "account_operator": "agency.com"} - initial = await evaluator.check(**claim) - assert initial.authorized - assert initial.expires_at == 1060 - assert initial.outage_expires_at == (grant_expiry or 1300) - offline[0], now[0] = True, (grant_expiry or 1300) - 0.001 - grace = await evaluator.check(**claim, allow_stale_on_outage=True) - assert grace.authorized and grace.stale_on_outage - assert grace.outage_expires_at == initial.outage_expires_at - now[0] = grant_expiry or 1300 - expired = await evaluator.check(**claim, allow_stale_on_outage=True) - assert not expired.authorized - assert expired.error_code == ("PERMISSION_DENIED" if grant_expiry else "SERVICE_UNAVAILABLE") - - -async def test_shared_grantor_latest_withdrawal_overrides_older_claim_snapshot(): - now, mode = [1000.0], ["allowed"] - - async def fetch(url, *, allow_private=False): - if mode[0] == "offline": - raise OSError("offline") - document = ( - {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - if url == trust_record_url(ORIGIN) - else { - "exclusive_grants": True, - "grants": [ - { - "grantee": "platform.com" if mode[0] == "allowed" else "other.com", - "scopes": ["adcp:media_buying"], - } - ], - } - ) - return TrustResponse(json.dumps(document).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - evaluator = TrustGrantEvaluator(records) - claim = { - "agent_origin": ORIGIN, - "brand_domain": "brand.com", - "account_operator": "platform.com", - } - assert (await evaluator.check(**claim)).authorized - mode[0], now[0] = "withdrawn", 1061 - await records.evaluate("brand.com") # Another account refreshes this grantor. - mode[0], now[0] = "offline", 1122 - result = await evaluator.check(**claim, allow_stale_on_outage=True) - assert not result.authorized - assert result.error_code == "PERMISSION_DENIED" - assert result.reason == "grant_absent" - - -async def test_captured_listing_cannot_approve_after_other_origin_refresh_delists_it(): - now, delisted = [1000.0], [False] - started, release = asyncio.Event(), asyncio.Event() - other_origin = "https://other.platform.com" - - async def fetch(url, *, allow_private=False): - if url == trust_record_url("brand.com"): - started.set() - await asyncio.wait_for(release.wait(), timeout=5) - document = {"grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}]} - elif url == trust_record_url(ORIGIN): - origins = [other_origin] if delisted[0] else [ORIGIN, other_origin] - document = {"agents": [{"url": origin, "roles": ["adcp:buying"]} for origin in origins]} - else: - document = {"agents": []} - return TrustResponse(json.dumps(document).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, max_records=2, clock=lambda: now[0]) - captured = await records.evaluate(ORIGIN, role="adcp:buying") - claim = { - "agent_origin": ORIGIN, - "brand_domain": "brand.com", - "account_operator": "platform.com", - } - pending = asyncio.create_task(TrustGrantEvaluator(records).check(**claim)) - await asyncio.wait_for(started.wait(), timeout=5) - try: - # Evict transport/cache entries while the original snapshot remains - # inside its TTL, allowing an independent origin to read newer policy. - await records.evaluate("other.com") - await records.evaluate("third.com") - delisted[0], now[0] = True, 1001 - newer = await records.evaluate(other_origin, role="adcp:buying") - assert newer.revision > captured.revision - assert not await records.snapshots_current((captured,)) - release.set() - result = await asyncio.wait_for(pending, timeout=5) - assert not result.authorized - assert result.error_code == "PERMISSION_DENIED" - assert not result.include_grant_absent_details - with pytest.raises(TrustRecordMatchError): - await records.validate_snapshots( - (captured,), origin_or_domain=ORIGIN, role="adcp:buying" - ) - finally: - release.set() - if not pending.done(): - await asyncio.wait_for(pending, timeout=5) - - -@pytest.mark.parametrize("signing_profile", ["web-bot-auth", LEGACY_PROFILE]) -async def test_brand_publication_during_legacy_callback_supersedes_old_fallback(signing_profile): - now, published = [1000.0], [False] - entered, release = asyncio.Event(), asyncio.Event() - - async def fetch(url, *, allow_private=False): - if url == trust_record_url(ORIGIN): - document = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - elif not published[0]: - return TrustResponse(b"", 404) - else: - document = {"grants": []} - return TrustResponse(json.dumps(document).encode()) - - async def legacy(request): - entered.set() - await asyncio.wait_for(release.wait(), timeout=5) - return True - - records = TrustRecordEvaluator( - fetcher=fetch, polling_interval_seconds=1800, clock=lambda: now[0] - ) - evaluator = TrustGrantEvaluator(records, legacy_authorization=legacy) - pending = asyncio.create_task( - evaluator.check( - agent_origin=ORIGIN, - brand_domain="brand.com", - account_operator="platform.com", - signing_profile=signing_profile, - ) - ) - await asyncio.wait_for(entered.wait(), timeout=5) - try: - # The negative retry cache expires while the operator remains fresh. - published[0], now[0] = True, 1061 - await records.evaluate("brand.com") - release.set() - result = await asyncio.wait_for(pending, timeout=5) - assert not result.authorized - assert result.error_code == "SERVICE_UNAVAILABLE" - assert result.reason == "trust_record_changed_during_evaluation" - assert not result.legacy_fallback_allowed - finally: - release.set() - if not pending.done(): - await asyncio.wait_for(pending, timeout=5) - - -@pytest.mark.parametrize("status", [404, 410, 500, 503, 429, 401, 403, 302]) -async def test_production_trust_fetch_preserves_error_status_without_consuming_body( - monkeypatch, status -): - consumed = [] - - class UnreadableErrorPage(httpx.AsyncByteStream): - async def __aiter__(self): - consumed.append(True) - raise AssertionError("Error response bodies must not be consumed") - yield b"" - - def respond(request): - return httpx.Response( - status, - headers={"content-encoding": "gzip", "content-length": str(3 * 1024 * 1024)}, - stream=UnreadableErrorPage(), - ) - - monkeypatch.setattr( - "adcp.signing.ip_pinned_transport.build_async_ip_pinned_transport", - lambda url, *, allow_private: httpx.MockTransport(respond), - ) - response = await fetch_trust_record(trust_record_url("brand.com")) - assert response.status == status - assert response.body == b"" - assert not consumed - - -@pytest.mark.parametrize("status", [404, 410, 500, 503, 429, 401, 403, 302]) -async def test_custom_fetcher_error_page_preserves_removal_or_outage_grace(status): - now, fail = [1000.0], [False] - - async def fetch(url, *, allow_private=False): - if url == trust_record_url(ORIGIN): - document = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - elif fail[0]: - return TrustResponse(b"x" * (3 * 1024 * 1024), status) - else: - document = {"grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}]} - return TrustResponse(json.dumps(document).encode()) - - evaluator = TrustGrantEvaluator(TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0])) - claim = { - "agent_origin": ORIGIN, - "brand_domain": "brand.com", - "account_operator": "platform.com", - } - assert (await evaluator.check(**claim)).authorized - fail[0], now[0] = True, 1061 - fresh = await evaluator.check(**claim) - assert not fresh.authorized - grace = await evaluator.check(**claim, allow_stale_on_outage=True) - if status in {404, 410}: - assert fresh.error_code == grace.error_code == "PERMISSION_DENIED" - assert fresh.reason == grace.reason == "grant_absent" - assert fresh.include_grant_absent_details and grace.include_grant_absent_details - else: - assert fresh.error_code == "SERVICE_UNAVAILABLE" - assert grace.authorized and grace.stale_on_outage - assert grace.outage_expires_at == 1300 - - -@pytest.mark.parametrize( - "guard_headers,read_timeout,total_deadline", - [ - ({"content-encoding": "gzip"}, False, False), - ({"content-length": str(3 * 1024 * 1024)}, False, False), - ({}, True, False), - ({}, False, True), - ], -) -async def test_rejected_production_200_still_supersedes_legacy_fallback( - monkeypatch, guard_headers, read_timeout, total_deadline -): - now, removed, legacy_calls = [1000.0], [False], [] - cancelled = [] - - if total_deadline: - original_wait_for = asyncio.wait_for - - async def shortened_deadline(awaitable, timeout): - return await original_wait_for(awaitable, timeout=1 if timeout == 10 else timeout) - - monkeypatch.setattr("adcp.signing.trust.asyncio.wait_for", shortened_deadline) - - class GuardedBody(httpx.AsyncByteStream): - async def __aiter__(self): - if read_timeout: - raise httpx.ReadTimeout("body stalled after HTTP 200") - if total_deadline: - try: - # Independent bound: removing the production deadline must - # fail this fixture promptly instead of hanging CI. - await original_wait_for(asyncio.Event().wait(), timeout=5) - except asyncio.CancelledError: - cancelled.append(True) - raise - pytest.fail("The 200 body guard must reject before consuming bytes") - yield b"" - - def respond(request): - return httpx.Response( - 404 if removed[0] else 200, headers=guard_headers, stream=GuardedBody() - ) - - monkeypatch.setattr( - "adcp.signing.ip_pinned_transport.build_async_ip_pinned_transport", - lambda url, *, allow_private: httpx.MockTransport(respond), - ) - - async def legacy(request): - legacy_calls.append(request) - return True - - evaluator = TrustGrantEvaluator( - TrustRecordEvaluator(clock=lambda: now[0]), legacy_authorization=legacy - ) - claim = { - "agent_origin": ORIGIN, - "brand_domain": "brand.com", - "account_operator": "platform.com", - "signing_profile": LEGACY_PROFILE, - } - rejected = await evaluator.check(**claim) - assert not rejected.authorized - assert rejected.error_code == ( - "SERVICE_UNAVAILABLE" if read_timeout or total_deadline else "PERMISSION_DENIED" - ) - assert bool(cancelled) == total_deadline - assert not rejected.include_grant_absent_details - removed[0], now[0] = True, 1061 - removal = await evaluator.check(**claim) - assert not removal.authorized - assert removal.reason == "grant_absent" - assert not removal.legacy_fallback_allowed - assert not legacy_calls diff --git a/tests/test_notification_preparation.py b/tests/test_notification_preparation.py index 02a62d06d..5e8d8e273 100644 --- a/tests/test_notification_preparation.py +++ b/tests/test_notification_preparation.py @@ -210,50 +210,6 @@ class Payload(BaseModel): assert json.loads(prepared.body)["notification_type"] == "principal.changed" -@pytest.mark.parametrize("reason", ["relationship_changed", "relationship_expiring"]) -async def test_experimental_principal_webhook_preparation_delivery_and_replay(reason: str) -> None: - from adcp.types.experimental_identity import PrincipalChangedWebhook - - payload = PrincipalChangedWebhook.model_validate( - {**notification_payload("principal_changed"), "reason": reason, "idempotency_key": KEY} - ) - with pytest.raises(ValueError, match="schema validation"): - WebhookSender.prepare_principal_changed(url=URL, payload=payload) - prepared = WebhookSender.prepare_principal_changed( - url=URL, payload=payload, experimental_agent_identity=True - ) - requests: list[httpx.Request] = [] - - def receive(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(202) - - async with httpx.AsyncClient(transport=httpx.MockTransport(receive)) as client: - sender = WebhookSender.from_bearer_token("test", client=client) - with pytest.raises(ValueError, match="schema validation"): - await sender.send_prepared(prepared) - result = await sender.send_prepared(prepared, experimental_agent_identity=True) - with pytest.raises(ValueError, match="schema validation"): - await sender.resend(result) - retry = await sender.resend(result, experimental_agent_identity=True) - direct = await sender.send_principal_changed( - url=URL, payload=payload, experimental_agent_identity=True - ) - assert result.ok and retry.ok and direct.ok - assert len(requests) == 3 - assert requests[0].content == requests[1].content == prepared.body - assert json.loads(prepared.body)["reason"] == reason - - -def test_experimental_principal_webhook_still_validates_stable_fields() -> None: - payload = {**notification_payload("principal_changed"), "reason": "relationship_changed"} - payload["changed_at"] = "not-a-timestamp" - with pytest.raises(ValueError, match="schema validation"): - WebhookSender.prepare_principal_changed( - url=URL, payload=payload, experimental_agent_identity=True - ) - - @pytest.mark.parametrize( "kind,args", [ diff --git a/tests/test_revocation_exposure_history.py b/tests/test_revocation_exposure_history.py deleted file mode 100644 index c14c46962..000000000 --- a/tests/test_revocation_exposure_history.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Authenticated refreshes cannot narrow previously observed compromise exposure.""" - -from datetime import datetime, timedelta, timezone - -import pytest - -from adcp.signing import AsyncCachingRevocationChecker, FetchResult -from tests.conformance.signing.test_async_revocation import ( - ISSUER, - _operator_key_and_resolver, - _ScriptedAsyncFetcher, - _sign_compact, -) - - -@pytest.mark.parametrize( - "first_detail,second_detail,exposes_earlier_receipt", - [ - ( - {"reason": "keyCompromise"}, - {"reason": "keyCompromise", "invalidity_date": "2026-04-18T00:00:00Z"}, - True, - ), - ( - {"reason": "keyCompromise", "invalidity_date": "2026-04-18T00:00:00Z"}, - {"reason": "keyCompromise"}, - True, - ), - ( - {"reason": "keyCompromise"}, - {"reason": "superseded", "invalidity_date": "2026-04-18T00:00:00Z"}, - True, - ), - ( - {"reason": "superseded", "invalidity_date": "2026-04-18T00:00:00Z"}, - {"reason": "keyCompromise"}, - True, - ), - ( - {"reason": "superseded"}, - {"reason": "keyCompromise", "invalidity_date": "2026-04-18T00:00:00Z"}, - False, - ), - ], -) -async def test_signed_refresh_retains_undated_compromise_exposure( - first_detail, second_detail, exposes_earlier_receipt -): - private, resolver = _operator_key_and_resolver() - now = [datetime(2026, 4, 18, 14, tzinfo=timezone.utc)] - elapsed = [0.0] - fetcher = _ScriptedAsyncFetcher() - - def enqueue(detail): - payload = { - "version": 1, - "issuer": ISSUER, - "updated": now[0].isoformat(), - "next_update": (now[0] + timedelta(seconds=60)).isoformat(), - "revoked_kids": ["compromised"], - "revoked_jtis": [], - "revocation_details": {"compromised": detail}, - } - fetcher.enqueue(FetchResult(_sign_compact(payload, private=private), etag=None)) - - enqueue(first_detail) - checker = AsyncCachingRevocationChecker.from_issuer_origin( - ISSUER, - jwks_resolver=resolver, - fetcher=fetcher, - clock=lambda: elapsed[0], - wall_clock=lambda: now[0], - ) - assert await checker("compromised") - now[0] += timedelta(seconds=61) - elapsed[0] += 61 - enqueue(second_detail) - assert await checker("compromised") - assert len(fetcher.calls) == 2 - detail = checker._current_list.revocation_details["compromised"] - assert detail.reason == "keyCompromise" - assert detail.exposes(datetime(2026, 4, 17, tzinfo=timezone.utc)) is exposes_earlier_receipt - assert detail.exposes(datetime(2026, 4, 19, tzinfo=timezone.utc)) diff --git a/tests/test_signed_request_verification.py b/tests/test_signed_request_verification.py index 8cadce025..2c150851a 100644 --- a/tests/test_signed_request_verification.py +++ b/tests/test_signed_request_verification.py @@ -969,16 +969,13 @@ def test_decisioning_serve_warns_when_required_signatures_are_unenforced() -> No with pytest.warns(UserWarning, match="not verifying request signatures"): _configure_request_signature_verification(_platform(capability), None, None, {}) - # Configuration itself emits the intentional 3.3 profile deprecation; - # the composition helper must emit no unenforced-signature warning. - verification = _config() with warnings.catch_warnings(): warnings.simplefilter("error") _configure_request_signature_verification( _platform(capability), None, None, - {"request_signature_verification": verification}, + {"request_signature_verification": _config()}, ) diff --git a/tests/test_wba_authorization_errors.py b/tests/test_wba_authorization_errors.py deleted file mode 100644 index adc72d003..000000000 --- a/tests/test_wba_authorization_errors.py +++ /dev/null @@ -1,735 +0,0 @@ -"""Established WBA identities use AdCP authorization errors on both transports.""" - -from __future__ import annotations - -import json -from typing import Any - -import httpx -import pytest -from asgi_lifespan import LifespanManager -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from mcp.types import CallToolResult - -from adcp.decisioning.registry import BuyerAgent -from adcp.decisioning.trust_grants import TrustGrantClaim, TrustGrantGate -from adcp.decisioning.types import Account, AdcpError -from adcp.protocols.mcp import extract_adcp_error -from adcp.server import ADCPHandler, RequestSignatureVerification -from adcp.server.serve import _build_mcp_and_a2a_app -from adcp.signing import ( - OriginBindings, - StaticSignerKeys, - TrustGrantEvaluator, - TrustRecordEvaluator, - TrustResponse, - public_directory_key, - sign_wba_request, - trust_record_url, -) - -ORIGIN = "https://buyer.platform.com" -CLAIM = TrustGrantClaim("brand.com", "platform.com") -BUYER = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") - - -def grant_gate(failure: str) -> tuple[TrustGrantGate, TrustRecordEvaluator]: - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - if url == trust_record_url(ORIGIN): - document: Any = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - elif failure == "offline": - raise OSError("brand trust record offline") - elif failure == "invalid": - return TrustResponse(b"not JSON") - else: - document = {"grants": []} - return TrustResponse(json.dumps(document).encode()) - - records = TrustRecordEvaluator(fetcher=fetch) - return TrustGrantGate(TrustGrantEvaluator(records), lambda account, agent: CLAIM), records - - -def changing_grant_gate( - *, initial: str, max_denials: int = 4096, grant_valid_until: str | None = None -) -> tuple[TrustGrantGate, list[str], list[float]]: - mode, now = [initial], [1000.0] - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - if url == trust_record_url(ORIGIN): - document = {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - elif mode[0] == "offline": - raise OSError("brand trust record offline") - else: - document = { - "exclusive_grants": True, - "grants": [ - { - "grantee": "platform.com" if mode[0] == "allowed" else "other.com", - "scopes": ["adcp:media_buying"], - } - ], - } - if grant_valid_until is not None: - document["grants"][0]["valid_until"] = grant_valid_until - return TrustResponse(json.dumps(document).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - gate = TrustGrantGate( - TrustGrantEvaluator(records), - lambda account, agent: account.metadata["claim"], - max_denials=max_denials, - ) - return gate, mode, now - - -async def require_existing_claim( - gate: TrustGrantGate, claim: TrustGrantClaim = CLAIM, buyer: BuyerAgent = BUYER -) -> None: - await gate.require_account( - Account(id="existing", metadata={"claim": claim}), - buyer, - operation="get_products", - bound_wba_identity=True, - ) - - -@pytest.mark.parametrize("initial", ["allowed", "denied"]) -async def test_known_grant_denial_never_becomes_authority_during_outage(initial: str) -> None: - gate, mode, now = changing_grant_gate(initial=initial) - if initial == "allowed": - await require_existing_claim(gate) - mode[0], now[0] = "denied", now[0] + 61 - with pytest.raises(AdcpError) as denied: - await require_existing_claim(gate) - assert denied.value.details == {"scope": "agent", "reason": "grant_absent"} - mode[0], now[0] = "offline", now[0] + 61 - with pytest.raises(AdcpError) as outage: - await require_existing_claim(gate) - assert outage.value.code == "PERMISSION_DENIED" - assert outage.value.details == denied.value.details - - -async def test_fresh_restored_grant_clears_previously_observed_denial() -> None: - gate, mode, now = changing_grant_gate(initial="denied") - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await require_existing_claim(gate) - mode[0], now[0] = "allowed", now[0] + 61 - await require_existing_claim(gate) - mode[0], now[0] = "offline", now[0] + 61 - await require_existing_claim(gate) - - -@pytest.mark.parametrize("grace_offset,allowed", [(239, True), (240, False), (241, False)]) -async def test_positive_claim_outage_continuity_has_a_fixed_grace_deadline( - grace_offset: int, allowed: bool -) -> None: - gate, mode, now = changing_grant_gate(initial="allowed") - await require_existing_claim(gate) - mode[0], now[0] = "offline", 1061 - await require_existing_claim(gate) - # Freshness expires at 1060; a successful outage read never refreshes the - # original four-polling-interval grace deadline of 1300. - now[0] = 1060 + grace_offset - if allowed: - await require_existing_claim(gate) - else: - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await require_existing_claim(gate) - - -async def test_initial_trust_outage_cannot_create_claim_authority() -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await gate.require_account( - Account(id="existing"), BUYER, operation="get_products", bound_wba_identity=True - ) - - -async def test_outage_grace_never_extends_an_explicit_grant_expiry() -> None: - gate, mode, now = changing_grant_gate( - initial="allowed", grant_valid_until="1970-01-01T00:17:50Z" - ) - await require_existing_claim(gate) - mode[0], now[0] = "offline", 1061 - await require_existing_claim(gate) - now[0] = 1070 - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await require_existing_claim(gate) - - -async def test_claim_aliases_and_country_order_cannot_bypass_known_denial() -> None: - gate, mode, now = changing_grant_gate(initial="denied") - claim = TrustGrantClaim("brand.com", "platform.com", brand_id="brand-1", countries=("US", "CA")) - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await require_existing_claim(gate, claim) - mode[0], now[0] = "offline", now[0] + 61 - alias_claim = TrustGrantClaim( - "https://Ads.BRAND.com/path", - "https://Agency.PLATFORM.com", - brand_id="brand-1", - countries=("CA", "US", "US"), - ) - alias_buyer = BuyerAgent( - agent_url="https://BUYER.PLATFORM.com:443/other", display_name="Buyer", status="active" - ) - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await require_existing_claim(gate, alias_claim, alias_buyer) - - -async def test_denial_admission_capacity_cannot_forget_a_withdrawn_claim() -> None: - gate, mode, now = changing_grant_gate(initial="denied", max_denials=1) - untracked = TrustGrantClaim("brand.com", "platform.com", brand_id="brand-2") - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await require_existing_claim(gate, CLAIM) - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await require_existing_claim(gate, untracked) - # Already admitted claims remain usable after overflow; an unadmitted - # claim cannot consume a slot or disable another claim's outage proof. - mode[0], now[0] = "allowed", now[0] + 61 - await require_existing_claim(gate) - mode[0], now[0] = "offline", now[0] + 61 - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await require_existing_claim(gate, untracked) - - -async def test_older_inflight_approval_cannot_clear_a_newer_claim_denial() -> None: - import asyncio - - entered, release = asyncio.Event(), asyncio.Event() - calls = 0 - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - document = ( - {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]} - if url == trust_record_url(ORIGIN) - else {"grants": []} - ) - return TrustResponse(json.dumps(document).encode()) - - async def seller_authorization(request: Any) -> bool: - nonlocal calls - calls += 1 - if calls == 1: - entered.set() - await asyncio.wait_for(release.wait(), timeout=5) - return True - return calls != 2 - - gate = TrustGrantGate( - TrustGrantEvaluator( - TrustRecordEvaluator(fetcher=fetch), seller_authorization=seller_authorization - ), - lambda account, agent: account.metadata["claim"], - ) - pending_approval = asyncio.create_task(require_existing_claim(gate)) - try: - await asyncio.wait_for(entered.wait(), timeout=5) - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await require_existing_claim(gate) - release.set() - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await asyncio.wait_for(pending_approval, timeout=5) - # A new evaluation begun after the denial can restore authority. - await require_existing_claim(gate) - finally: - release.set() - if not pending_approval.done(): - pending_approval.cancel() - await asyncio.wait_for(asyncio.gather(pending_approval, return_exceptions=True), timeout=5) - - -@pytest.mark.parametrize("failure", ["offline", "invalid", "grant_absent"]) -async def test_grant_error_codes_separate_outage_from_authorization(failure: str) -> None: - gate, _ = grant_gate(failure) - with pytest.raises(AdcpError) as rejected: - await gate.require_claim(CLAIM, BUYER) - error = rejected.value - if failure == "offline": - assert error.code == "SERVICE_UNAVAILABLE" - assert error.recovery == "transient" - assert not error.details - else: - assert error.code == "PERMISSION_DENIED" - assert error.recovery == "correctable" - assert error.details == ( - {"scope": "agent", "reason": "grant_absent"} if failure == "grant_absent" else {} - ) - - -@pytest.mark.parametrize( - "operation,params", - [ - ("get_products", None), - ("get_media_buys", None), - ("sync_creatives", None), - ("provide_performance_feedback", None), - ("control_media_buy", {"canceled": True}), - ("update_media_buy", {"paused": True}), - ], -) -async def test_bound_existing_account_nonspend_continues_on_true_outage( - operation: str, params: dict[str, Any] | None -) -> None: - gate, mode, now = changing_grant_gate(initial="allowed") - account = Account(id="existing", metadata={"claim": CLAIM}) - for state in ("allowed", "offline"): - mode[0] = state - await gate.require_account( - account, BUYER, operation=operation, operation_params=params, bound_wba_identity=True - ) - now[0] += 61 - - -@pytest.mark.parametrize("operation", ["sync_accounts", "create_media_buy", "unknown_operation"]) -async def test_provisioning_spend_and_unknown_operations_require_fresh_authority( - operation: str, -) -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError) as rejected: - await gate.require_account( - Account(id="existing"), BUYER, operation=operation, bound_wba_identity=True - ) - # An unknown task also requires its own explicit grant scope. - assert rejected.value.code == ( - "PERMISSION_DENIED" if operation == "unknown_operation" else "SERVICE_UNAVAILABLE" - ) - - -async def test_new_account_declaration_never_uses_cached_binding_to_create_authority() -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError) as rejected: - await gate.require_declaration( - {"brand": {"domain": "brand.com"}, "operator": "platform.com"}, BUYER - ) - assert rejected.value.code == "SERVICE_UNAVAILABLE" - - -@pytest.mark.parametrize( - "params", - [ - {"paused": False}, - {"total_budget": 1000}, - {"daily_budget_cap": None}, - {"packages": [{"budget": 500}]}, - ], -) -async def test_resume_and_financial_changes_require_fresh_grant_authority( - params: dict[str, Any], -) -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await gate.require_account( - Account(id="existing"), - BUYER, - operation="update_media_buy", - operation_params=params, - bound_wba_identity=True, - ) - - -async def test_seller_spend_classification_is_authoritative_for_nonspend_default() -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await gate.require_account( - Account(id="existing"), - BUYER, - operation="sync_creatives", - bound_wba_identity=True, - spend_committing=True, - ) - - -async def test_handler_preserves_explicit_financial_null_for_spend_classification() -> None: - from concurrent.futures import ThreadPoolExecutor - - from pydantic import BaseModel - - from adcp.decisioning import ( - AuthInfo, - DecisioningCapabilities, - DecisioningPlatform, - HttpSigCredential, - InMemoryTaskRegistry, - SingletonAccounts, - signing_only_registry, - ) - from adcp.decisioning.handler import PlatformHandler - from adcp.server import ToolContext - - class Change(BaseModel): - daily_budget_cap: int | None = None - - class Platform(DecisioningPlatform): - capabilities = DecisioningCapabilities() - accounts = SingletonAccounts(account_id="existing") - - async def lookup(origin: str) -> BuyerAgent: - return BUYER - - context = ToolContext( - metadata={ - "adcp.auth_info": AuthInfo( - kind="http_sig", - credential=HttpSigCredential( - kind="http_sig", keyid="kid", agent_url=ORIGIN, verified_at=1000 - ), - extra={"signing_profile": "web-bot-auth", "principal_id": "approved-principal"}, - ) - } - ) - gate, _ = grant_gate("offline") - with ThreadPoolExecutor(max_workers=1) as executor: - handler = PlatformHandler( - Platform(), - executor=executor, - registry=InMemoryTaskRegistry(), - buyer_agent_registry=signing_only_registry(lookup), - trust_grant_gate=gate, - ) - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await handler._resolve_account( - None, - context, - tool_name="update_media_buy", - request_params=Change(daily_budget_cap=None), - ) - - -async def test_unbound_read_does_not_inherit_cached_commercial_binding() -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): - await gate.require_account(Account(id="existing"), BUYER, operation="get_products") - - -async def test_unestablished_agent_keeps_oracle_clamp_even_during_outage() -> None: - gate, _ = grant_gate("offline") - with pytest.raises(AdcpError) as rejected: - await gate.require_claim(CLAIM, None) - assert rejected.value.code == "PERMISSION_DENIED" - assert not rejected.value.details - - -@pytest.mark.parametrize("failure", ["invalid", "grant_absent"]) -async def test_cached_binding_does_not_override_missing_or_invalid_grants(failure: str) -> None: - gate, _ = grant_gate(failure) - with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await gate.require_account( - Account(id="existing"), BUYER, operation="get_products", bound_wba_identity=True - ) - - -@pytest.mark.parametrize("transport", ["mcp", "a2a"]) -@pytest.mark.parametrize("failure", ["offline", "invalid", "grant_absent"]) -async def test_valid_signed_authorization_failure_reaches_standard_client_error_envelope( - transport: str, failure: str, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("ADCP_SANDBOX", "1") - gate, records = grant_gate(failure) - private = Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - bindings = OriginBindings() - bindings.bind(ORIGIN, "approved-principal") - - class Handler(ADCPHandler[Any]): - async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: - assert context.metadata["adcp.auth_info"].extra["principal_id"] == "approved-principal" - await gate.require_claim(CLAIM, BUYER) - raise AssertionError("authorization failure must prevent handler side effects") - - async def not_revoked(origin: str, keyid: str) -> bool: - return False - - app = _build_mcp_and_a2a_app( - Handler(), - name="authorization-test", - port=0, - host="127.0.0.1", - instructions=None, - test_controller=None, - validation=None, - advertise_all=True, - stateless_http=True, - allowed_hosts=["localhost"], - request_signature_verification=RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - enable_web_bot_auth=True, - origin_bindings=bindings, - trust_records=records, - wba_revocation_checker=not_revoked, - ), - ) - if transport == "mcp": - path = "/mcp" - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "get_products", "arguments": {}}, - } - else: - path = "/" - payload = { - "jsonrpc": "2.0", - "id": "1", - "method": "message/send", - "params": { - "message": { - "messageId": "m1", - "role": "user", - "parts": [ - {"kind": "data", "data": {"skill": "get_products", "parameters": {}}} - ], - } - }, - } - body = json.dumps(payload).encode() - headers = {"content-type": "application/json", "accept": "application/json, text/event-stream"} - headers.update( - sign_wba_request( - method="POST", - url="http://localhost" + path, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - async with LifespanManager(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app), base_url="http://localhost" - ) as client: - response = await client.post(path, content=body, headers=headers) - response.raise_for_status() - assert "accept-signature" not in response.headers - wire = response.json()["result"] - if transport == "mcp": - decoded = CallToolResult.model_validate(wire) - assert decoded.is_error - error = extract_adcp_error(decoded) - else: - assert wire["status"]["state"] == "failed" - error = next( - part["data"]["adcp_error"] - for artifact in wire["artifacts"] - for part in artifact["parts"] - if part.get("kind") == "data" and "adcp_error" in part.get("data", {}) - ) - assert error is not None - assert error["code"] == ("SERVICE_UNAVAILABLE" if failure == "offline" else "PERMISSION_DENIED") - assert error["recovery"] == ("transient" if failure == "offline" else "correctable") - assert error.get("details", {}) == ( - {"scope": "agent", "reason": "grant_absent"} if failure == "grant_absent" else {} - ) - await assert_public_typed_error(transport, wire, error, monkeypatch) - - -async def assert_public_typed_error( - transport: str, wire: dict[str, Any], error: dict[str, Any], monkeypatch: pytest.MonkeyPatch -) -> None: - # Feed the actual wire error through the public typed client too. Returning - # HTTP 200 is insufficient if typed parsing drops the retry/code metadata. - from unittest.mock import AsyncMock - - from adcp import ADCPClient, AgentConfig - from adcp.types import GetProductsRequest - - if transport == "mcp": - decoded = CallToolResult.model_validate(wire) - - class Session: - async def call_tool( - self, name: str, arguments: dict[str, Any], **kwargs: Any - ) -> CallToolResult: - assert name == "get_products" - return decoded - - typed_client = ADCPClient.from_mcp_client(Session(), agent_id="seller") - else: - from a2a import types as pb - from google.protobuf.json_format import ParseDict - from google.protobuf.struct_pb2 import Value - - typed_client = ADCPClient( - AgentConfig(id="seller", agent_uri="http://localhost", protocol="a2a") - ) - # The HTTP binding uses the legacy JSON shape; the A2A SDK passes - # protobuf tasks to our adapter after decoding that binding. - parts = [ - pb.Part(data=ParseDict(part["data"], Value())) - for artifact in wire["artifacts"] - for part in artifact["parts"] - if part.get("kind") == "data" - ] - task = pb.Task( - status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_FAILED), - artifacts=[pb.Artifact(parts=parts)], - ) - raw_result = typed_client.adapter._process_task_response(task, None) - monkeypatch.setattr( - typed_client.adapter, "get_products", AsyncMock(return_value=raw_result) - ) - typed_result = await typed_client.get_products( - GetProductsRequest(buying_mode="brief", brief="Any") - ) - assert not typed_result.success - assert typed_result.adcp_error == error - assert typed_result.adcp_error["recovery"] == error["recovery"] - - -@pytest.mark.parametrize("transport", ["mcp", "a2a"]) -@pytest.mark.parametrize( - "signer_failure", - ["revoked", "directory_offline", "directory_503", "directory_429", "revocation_offline"], -) -async def test_revoked_key_projects_error_without_authentication_or_business_effects( - transport: str, signer_failure: str, monkeypatch: pytest.MonkeyPatch -) -> None: - from adcp.server import ToolContext - from adcp.server.auth import BearerTokenAuth, current_principal - from adcp.server.signed_requests import current_verified_signer, verified_signer_for_request - from adcp.signing import DirectoryResponse, DiscoveredSignerKeys, OriginRevocationChecker - - monkeypatch.setenv("ADCP_SANDBOX", "1") - private = Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - effects: list[str] = [] - observations: list[str] = [] - - class Handler(ADCPHandler[Any]): - async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: - effects.append("handler") - return {"products": []} - - async def validate_token(token: str) -> None: - effects.append("bearer lookup") - return None - - async def business_middleware(skill: str, params: Any, context: Any, call_next: Any) -> Any: - effects.append("business middleware") - return await call_next() - - def context_factory(metadata: Any) -> ToolContext: - observations.append(metadata.transport) - assert current_verified_signer.get() is None - assert current_principal.get() is None - assert verified_signer_for_request(metadata.request_context) is None - scope = getattr(metadata.request_context, "scope", None) - if scope is not None: - assert not getattr(scope.get("user"), "is_authenticated", False) - return ToolContext() - - async def revoked(origin: str, keyid: str) -> bool: - assert origin == ORIGIN and keyid == jwk["kid"] - return signer_failure == "revoked" - - async def directory_fetch(url: str, *, allow_private: bool = False) -> DirectoryResponse: - if signer_failure == "directory_offline": - raise OSError("directory offline") - return DirectoryResponse(b"", {}, status=503 if signer_failure == "directory_503" else 429) - - async def revocation_fetch(url: str, *, allow_private: bool = False) -> Any: - raise OSError("revocation list unavailable past grace") - - signer_keys = ( - DiscoveredSignerKeys(fetcher=directory_fetch) - if signer_failure.startswith("directory_") - else StaticSignerKeys({ORIGIN: {"keys": [jwk]}}) - ) - revocation_checker = ( - OriginRevocationChecker(signer_keys, fetcher=revocation_fetch) - if signer_failure == "revocation_offline" - else revoked - ) - - config = RequestSignatureVerification( - signer_keys=signer_keys, - enable_web_bot_auth=True, - wba_revocation_checker=revocation_checker, - ) - app = _build_mcp_and_a2a_app( - Handler(), - name="revoked-test", - port=0, - host="127.0.0.1", - instructions=None, - test_controller=None, - validation=None, - advertise_all=True, - stateless_http=True, - allowed_hosts=["localhost"], - auth=BearerTokenAuth(validate_token=validate_token), - context_factory=context_factory, - middleware=[business_middleware], - request_signature_verification=config, - ) - path = "/mcp" if transport == "mcp" else "/" - params = ( - {"name": "get_products", "arguments": {}} - if transport == "mcp" - else { - "message": { - "messageId": "revoked-m1", - "role": "user", - "parts": [{"kind": "data", "data": {"skill": "get_products", "parameters": {}}}], - } - } - ) - body = json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call" if transport == "mcp" else "message/send", - "params": params, - } - ).encode() - headers = {"content-type": "application/json", "accept": "application/json, text/event-stream"} - headers.update( - sign_wba_request( - method="POST", - url="http://localhost" + path, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - async with LifespanManager(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app), base_url="http://localhost" - ) as client: - response = await client.post(path, content=body, headers=headers) - # Error-only admission must not survive to an unrelated unsigned call. - unsigned = await client.post( - path, - content=body, - headers={ - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - ) - response.raise_for_status() - assert "accept-signature" not in response.headers - assert unsigned.status_code == 401 - assert not effects - assert observations == [transport] - assert current_verified_signer.get() is None - assert current_principal.get() is None - wire = response.json()["result"] - if transport == "mcp": - decoded = CallToolResult.model_validate(wire) - assert decoded.is_error - error = extract_adcp_error(decoded) - else: - assert wire["status"]["state"] == "failed" - error = next( - part["data"]["adcp_error"] - for artifact in wire["artifacts"] - for part in artifact["parts"] - if part.get("kind") == "data" - ) - assert error is not None and error["code"] == ( - "PERMISSION_DENIED" if signer_failure == "revoked" else "SERVICE_UNAVAILABLE" - ) - assert error["recovery"] == ("correctable" if signer_failure == "revoked" else "transient") - assert not error.get("details") - await assert_public_typed_error(transport, wire, error, monkeypatch) diff --git a/tests/test_wba_binding_authority.py b/tests/test_wba_binding_authority.py deleted file mode 100644 index 572cfbac5..000000000 --- a/tests/test_wba_binding_authority.py +++ /dev/null @@ -1,774 +0,0 @@ -"""Binding authorization stays fresh independently of transport key discovery.""" - -from __future__ import annotations - -import asyncio -import json -import time -from contextlib import asynccontextmanager -from dataclasses import replace - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric import ed25519 -from starlette.requests import Request -from starlette.responses import JSONResponse - -from adcp.decisioning.types import AdcpError -from adcp.server.signed_requests import ( - RequestSignatureVerification, - SignedRequestVerificationMiddleware, - apply_verified_signer, - check_wba_authorization, - is_wba_spend_committing, -) -from adcp.server.translate import build_mcp_error_result -from adcp.signing import ( - OriginBindings, - StaticSignerKeys, - TrustRecordEvaluator, - TrustResponse, - VerifiedSigner, - public_directory_key, - sign_request, - sign_wba_request, -) -from adcp.signing.middleware import VERIFIED_SIGNER_SCOPE_KEY -from adcp.signing.trust import TrustRecordMatchError, TrustRecordNotFoundError - -ORIGIN = "https://buyer.platform.com" -TARGET = "https://seller.com/mcp" - - -async def not_revoked(origin, keyid): - return False - - -def test_a2a_list_tasks_alias_is_nonspend_and_unknown_operations_fail_closed(): - assert not is_wba_spend_committing("ListTasks") - assert not is_wba_spend_committing("tasks/list") - assert not is_wba_spend_committing("GetTask") - assert is_wba_spend_committing("FutureSpendTask") - assert is_wba_spend_committing("") - - -@pytest.mark.parametrize("revoked", [False, True, "unavailable"]) -@pytest.mark.parametrize("transport", ["mcp", "a2a"]) -async def test_standalone_middleware_never_calls_app_that_ignores_pending_denial( - revoked, transport -): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - invoked = [] - - async def check_key(origin, keyid): - if revoked == "unavailable": - raise OSError("private outage details") - return revoked - - async def unsafe_dispatch(scope, receive, send): - invoked.append(True) - await JSONResponse({"side_effect": True})(scope, receive, send) - - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - enable_web_bot_auth=True, - wba_revocation_checker=check_key, - ) - app = SignedRequestVerificationMiddleware(unsafe_dispatch, config, transport=transport) - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "create_media_buy", "arguments": {}}, - } - if transport == "a2a": - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": "message/send", - "params": { - "message": { - "messageId": "guard", - "role": "user", - "parts": [ - {"kind": "data", "data": {"skill": "create_media_buy", "parameters": {}}} - ], - } - }, - } - body = json.dumps(payload).encode() - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: - response = await client.post(TARGET, content=body, headers=headers) - assert response.status_code == (503 if revoked == "unavailable" else 401) - if revoked == "unavailable": - assert response.json() == { - "errors": [ - { - "code": "SERVICE_UNAVAILABLE", - "message": "Signer identity is temporarily unavailable", - "recovery": "transient", - } - ] - } - else: - assert response.json() == {"error": "unauthenticated"} - assert "Accept-Signature" not in response.headers - assert not invoked - - -@asynccontextmanager -async def bound_client( - *, transport="mcp", unbound_for=(), spend_classifier=None, defer_authorization_errors=True -): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - now, mode = [1000], ["valid"] - - async def fetch(url, *, allow_private=False): - if mode[0] == "offline_late": - now[0] = 1300 - raise OSError("fetch crossed the grace deadline") - if mode[0] == "offline": - raise OSError("offline") - if mode[0] == "removed": - return TrustResponse(b"", 404) - if mode[0] == "invalid": - return TrustResponse(b'{"agents":NaN}') - roles = ["adcp:sales"] if mode[0] == "role_changed" else ["adcp:buying"] - agents = [] if mode[0] == "delisted" else [{"url": ORIGIN, "roles": roles}] - return TrustResponse(json.dumps({"agents": agents}).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - bindings = OriginBindings() - approved = await records.bind( - VerifiedSigner( - jwk["kid"], "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ), - principal_id="principal-1", - bindings=bindings, - ) - assert approved.operator_domain == "platform.com" - options = {"spend_classifier": spend_classifier} if spend_classifier is not None else {} - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - enable_web_bot_auth=True, - origin_bindings=bindings, - trust_records=records, - wba_revocation_checker=not_revoked, - unbound_for=frozenset(unbound_for), - **options, - ) - observed = [] - - async def dispatch(scope, receive, send): - context = apply_verified_signer(None, Request(scope)) - try: - check_wba_authorization(context) - except AdcpError as exc: - await JSONResponse(build_mcp_error_result(exc).model_dump(mode="json", by_alias=True))( - scope, receive, send - ) - return - principal = context.metadata["adcp.auth_info"].extra["principal_id"] - user = scope.get("user") - observed.append((principal, getattr(user, "display_name", None))) - await JSONResponse({"principal": principal})(scope, receive, send) - - app = SignedRequestVerificationMiddleware( - dispatch, config, transport=transport, defer_authorization_errors=defer_authorization_errors - ) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: - - async def post(operation, arguments=None, *, method="tools/call"): - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": ( - {"name": operation, "arguments": arguments or {}} - if method == "tools/call" - else {"id": "owned-task"} - ), - } - body = json.dumps(payload).encode() - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - return await client.post(TARGET, content=body, headers=headers) - - yield post, now, mode, bindings, records, observed, config, dispatch - - -@pytest.mark.parametrize("mode_name", ["delisted", "removed", "role_changed"]) -async def test_delisted_binding_retains_anonymous_reads_principal_and_prevents_rebinding(mode_name): - async with bound_client(unbound_for={"get_products"}) as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - assert (await post("get_products")).json()["principal"] == "principal-1" - mode[0], now[0] = mode_name, 1061 - response = await post("get_products") - assert response.json()["principal"] is None - assert (await post("get_principal")).json()["principal"] is None - assert await bindings(ORIGIN) == "principal-1" - signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ) - with pytest.raises((TrustRecordMatchError, ValueError)): - await records.bind(signer, principal_id="principal-1", bindings=bindings) - mode[0], now[0] = "offline", 1122 - assert (await post("get_products")).json()["principal"] is None - response = await post("create_media_buy") - assert response.json()["structuredContent"]["adcp_error"]["code"] == "PERMISSION_DENIED" - assert "Accept-Signature" not in response.headers - - -@pytest.mark.parametrize( - "operation,arguments", - [ - ("get_media_buys", {}), - ("sync_creatives", {}), - ("control_media_buy", {"paused": True}), - ("control_media_buy", {"canceled": True}), - ("update_media_buy", {"name": "new label"}), - ], -) -async def test_operator_outage_keeps_cached_binding_for_nonspend_calls(operation, arguments): - async with bound_client() as (post, now, mode, bindings, records, observed, config, dispatch): - assert (await post(operation, arguments)).json()["principal"] == "principal-1" - mode[0], now[0] = "offline", 1061 - assert (await post(operation, arguments)).json()["principal"] == "principal-1" - - -async def test_binding_outage_grace_boundary_is_shared_across_transport_ports(): - async with bound_client() as (post, now, mode, bindings, records, observed, config, dispatch): - assert (await post("get_media_buys")).json()["principal"] == "principal-1" - mode[0], now[0] = "offline", 1299.999 - assert (await post("get_media_buys")).json()["principal"] == "principal-1" - a2a = SignedRequestVerificationMiddleware( - dispatch, config, transport="a2a", defer_authorization_errors=True - ) - signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ) - established, error = await a2a._authorize_wba_signer(signer, "get_media_buys") - assert error is None - assert established.principal_id == "principal-1" - now[0] = 1300 - response = await post("get_media_buys") - error = response.json()["structuredContent"]["adcp_error"] - assert error["code"] == "SERVICE_UNAVAILABLE" - assert error["recovery"] == "transient" - assert "Accept-Signature" not in response.headers - _, error = await a2a._authorize_wba_signer(signer, "get_media_buys") - assert error.code == "SERVICE_UNAVAILABLE" - assert len(observed) == 2 - - -async def test_binding_outage_grace_is_rechecked_after_slow_fetch(): - async with bound_client() as (post, now, mode, bindings, records, observed, config, dispatch): - mode[0], now[0] = "offline_late", 1061 - response = await post("get_media_buys") - assert response.json()["structuredContent"]["adcp_error"]["code"] == "SERVICE_UNAVAILABLE" - assert now[0] == 1300 - assert not observed - - -@pytest.mark.parametrize("protocol_method", [False, True]) -async def test_binding_outage_outside_grace_returns_service_on_edge_only_paths(protocol_method): - async with bound_client( - transport="a2a" if protocol_method else "mcp", - defer_authorization_errors=protocol_method, - ) as (post, now, mode, bindings, records, observed, config, dispatch): - mode[0], now[0] = "offline", 1300 - response = await post( - "get_media_buys", method="tasks/get" if protocol_method else "tools/call" - ) - assert response.status_code == 503 - error = response.json()["errors"][0] - assert error["code"] == "SERVICE_UNAVAILABLE" - assert error["recovery"] == "transient" - assert "Accept-Signature" not in response.headers - assert not observed - - -@pytest.mark.parametrize("newer_policy", ["delisted", "removed", "expired"]) -async def test_binding_final_check_rejects_newer_policy_or_time_after_evaluation( - monkeypatch, newer_policy -): - async with bound_client(unbound_for={"get_media_buys"}) as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - evaluate = records.evaluate - - async def delayed_evaluate(origin, **kwargs): - captured = await evaluate(origin, **kwargs) - if newer_policy == "expired": - now[0] = captured.expires_at - else: - mode[0], now[0] = newer_policy, 1061 - try: - await evaluate("https://other.platform.com") - except TrustRecordNotFoundError: - # Retain the removal, then return the older in-flight listing - # so the framework's final guard must reject that snapshot. - assert newer_policy == "removed" - return captured - - monkeypatch.setattr(records, "evaluate", delayed_evaluate) - response = await post("get_media_buys") - if newer_policy == "expired": - assert ( - response.json()["structuredContent"]["adcp_error"]["code"] == "SERVICE_UNAVAILABLE" - ) - assert not observed - else: - assert response.json()["principal"] is None - assert observed == [(None, None)] - - -async def test_historical_binding_without_prior_listing_cannot_continue_through_outage(): - async with bound_client() as (post, now, mode, bindings, records, observed, config, dispatch): - - async def unavailable(url, *, allow_private=False): - raise OSError("offline after restart") - - restarted = TrustRecordEvaluator(fetcher=unavailable, clock=lambda: now[0]) - middleware = SignedRequestVerificationMiddleware( - dispatch, replace(config, trust_records=restarted), transport="mcp" - ) - signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ) - _, error = await middleware._authorize_wba_signer(signer, "get_media_buys") - assert error.code == "SERVICE_UNAVAILABLE" - assert error.recovery == "transient" - - -async def test_other_origins_delisting_read_survives_cache_eviction_and_local_capacity(): - async with bound_client(unbound_for={"get_media_buys"}) as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - assert (await post("get_media_buys")).json()["principal"] == "principal-1" - mode[0], now[0] = "delisted", 1061 - # A different origin's read, without requesting this origin's role, - # still supersedes its historical positive membership. - await records.evaluate("https://other.platform.com") - records._cache.clear() - records._max_records = 1 - release = asyncio.Event() - pending = asyncio.create_task(release.wait()) - records._pending["unrelated.com"] = pending - try: - mode[0], now[0] = "offline", 1122 - response = await post("get_media_buys") - assert response.json()["principal"] is None - assert await bindings(ORIGIN) == "principal-1" - # A spend request made through a second middleware has never - # observed the origin's membership itself; latest owner policy - # still takes precedence over a local discovery-capacity outage. - fresh_config = replace(config) - fresh_config._binding_membership.clear() - other_port = SignedRequestVerificationMiddleware( - dispatch, fresh_config, transport="a2a", defer_authorization_errors=True - ) - signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ) - established, error = await other_port._authorize_wba_signer(signer, "create_media_buy") - assert established.principal_id is None - assert error.code == "PERMISSION_DENIED" - finally: - records._pending.pop("unrelated.com") - release.set() - assert await asyncio.wait_for(pending, timeout=5) - - -@pytest.mark.parametrize( - "operation,arguments", - [ - ("create_media_buy", {}), - ("buy_products", {}), - ("accept_proposal", {}), - ("control_media_buy", {"paused": False}), - ("control_media_buy", {"total_budget": {"amount": 500}}), - ("update_media_buy", {"new_packages": [{}]}), - ("future_paid_task", {}), - ], -) -async def test_operator_outage_stops_new_or_unknown_spend_with_retryable_adcp_error( - operation, arguments -): - async with bound_client() as (post, now, mode, bindings, records, observed, config, dispatch): - mode[0], now[0] = "offline", 1061 - response = await post(operation, arguments) - error = response.json()["structuredContent"]["adcp_error"] - assert error["code"] == "SERVICE_UNAVAILABLE" - assert error["recovery"] == "transient" - assert not observed - assert "Accept-Signature" not in response.headers - - -async def test_seller_can_classify_known_budget_reductions_using_local_state(): - async with bound_client(spend_classifier=lambda operation, arguments: False) as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - mode[0], now[0] = "offline", 1061 - assert (await post("control_media_buy", {"total_budget": {"amount": 50}})).json()[ - "principal" - ] == "principal-1" - - -async def test_invalid_operator_record_then_outage_cannot_restore_commercial_binding(): - async with bound_client(unbound_for={"get_products"}) as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - assert (await post("get_products")).json()["principal"] == "principal-1" - mode[0], now[0] = "invalid", 1061 - invalid = await post("get_products") - assert invalid.json()["structuredContent"]["adcp_error"]["code"] == "PERMISSION_DENIED" - mode[0], now[0] = "offline", 1122 - assert (await post("get_products")).json()["structuredContent"]["adcp_error"][ - "code" - ] == "PERMISSION_DENIED" - assert (await post("create_media_buy")).json()["structuredContent"]["adcp_error"][ - "code" - ] == "PERMISSION_DENIED" - mode[0], now[0] = "valid", 1183 - assert (await post("get_products")).json()["principal"] == "principal-1" - - -async def test_delisted_membership_is_shared_across_mcp_and_a2a_middleware(): - async with bound_client(unbound_for={"get_products"}) as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - mode[0], now[0] = "delisted", 1061 - assert (await post("get_products")).json()["principal"] is None - mode[0], now[0] = "offline", 1122 - a2a = SignedRequestVerificationMiddleware( - dispatch, config, transport="a2a", defer_authorization_errors=True - ) - signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ) - established, error = await a2a._authorize_wba_signer(signer, "create_media_buy") - assert established.principal_id is None - assert error.code == "PERMISSION_DENIED" - - -@pytest.mark.parametrize("method", ["tasks/get", "tasks/cancel"]) -async def test_delisted_a2a_origin_keeps_only_historical_task_owner(method): - async with bound_client(transport="a2a") as ( - post, - now, - mode, - bindings, - records, - observed, - config, - dispatch, - ): - assert (await post("unused", method=method)).status_code == 200 - assert observed == [("principal-1", ORIGIN)] - mode[0], now[0] = "delisted", 1061 - response = await post("unused", method=method) - assert response.status_code == 200 - assert observed == [("principal-1", ORIGIN), (None, ORIGIN)] - assert "Accept-Signature" not in response.headers - - -@pytest.mark.parametrize("preverified", [False, True]) -@pytest.mark.parametrize("failure", ["delisted", "offline"]) -async def test_bound_identity_loses_authority_on_refresh_without_legacy_downgrade( - preverified, failure -): - private = ed25519.Ed25519PrivateKey.generate() - jwk = { - **public_directory_key(private), - "alg": "EdDSA", - "adcp_use": "request-signing", - "key_ops": ["verify"], - } - now, mode, fetches, dispatched = [1000], ["valid"], [], [] - - async def fetch(url, *, allow_private=False): - fetches.append(url) - if mode[0] == "offline": - raise OSError("offline") - agents = [{"url": ORIGIN, "roles": ["adcp:buying"]}] if mode[0] == "valid" else [] - return TrustResponse(json.dumps({"agents": agents}).encode()) - - records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) - bindings = OriginBindings() - await records.bind( - VerifiedSigner( - jwk["kid"], "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ), - principal_id="principal-1", - bindings=bindings, - ) - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - enable_web_bot_auth=True, - origin_bindings=bindings, - trust_records=records, - wba_revocation_checker=not_revoked, - signing_profile_version="3.2", - ) - - async def dispatch(scope, receive, send): - context = apply_verified_signer(None, Request(scope)) - try: - check_wba_authorization(context) - except AdcpError as exc: - await JSONResponse(build_mcp_error_result(exc).model_dump(mode="json", by_alias=True))( - scope, receive, send - ) - return - dispatched.append(True) - await JSONResponse({"ok": True})(scope, receive, send) - - middleware = SignedRequestVerificationMiddleware( - dispatch, config, transport="mcp", defer_authorization_errors=True - ) - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "create_media_buy", "arguments": {}}, - } - body = json.dumps(payload).encode() - - async def app(scope, receive, send): - if preverified: - scope[VERIFIED_SIGNER_SCOPE_KEY] = VerifiedSigner( - jwk["kid"], - "ed25519", - "sig1", - time.time(), - agent_url=ORIGIN, - profile="web-bot-auth", - parsed_body=payload, - ) - await middleware(scope, receive, send) - - async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: - - async def post(): - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - return await client.post(TARGET, content=body, headers=headers) - - assert (await post()).status_code == 200 - mode[0] = failure - now[0] += 61 - response = await post() - assert response.status_code == 200 - error = response.json()["structuredContent"]["adcp_error"] - assert error["code"] == ( - "PERMISSION_DENIED" if failure == "delisted" else "SERVICE_UNAVAILABLE" - ) - assert error["recovery"] == ("correctable" if failure == "delisted" else "transient") - assert "Accept-Signature" not in response.headers - assert len(dispatched) == 1 - assert len(fetches) == 2 - assert await bindings(ORIGIN) == "principal-1" - - # Historical approval must still prevent transport downgrade. - headers = {"Content-Type": "application/json"} - signed = sign_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - private_key=private, - key_id=jwk["kid"], - alg="ed25519", - signing_profile_version="3.2", - ) - legacy = SignedRequestVerificationMiddleware( - dispatch, config, transport="mcp", defer_authorization_errors=True - ) - - async def legacy_app(scope, receive, send): - if preverified: - scope[VERIFIED_SIGNER_SCOPE_KEY] = VerifiedSigner( - jwk["kid"], - "ed25519", - "sig1", - time.time(), - agent_url=ORIGIN, - parsed_body=payload, - ) - await legacy(scope, receive, send) - - async with httpx.AsyncClient(transport=httpx.ASGITransport(legacy_app)) as legacy_client: - response = await legacy_client.post( - TARGET, content=body, headers={**headers, **signed.as_dict()} - ) - assert response.status_code == 200 - assert response.json()["structuredContent"]["adcp_error"]["code"] == "PERMISSION_DENIED" - assert "Accept-Signature" not in response.headers - assert len(dispatched) == 1 - - -@pytest.mark.parametrize("operation", ["get_products", "list_creative_formats", "sync_principal"]) -async def test_unbound_policy_can_expose_anonymous_reads_and_explicit_open_onboarding(operation): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - dispatched = [] - - async def dispatch(scope, receive, send): - context = apply_verified_signer(None, Request(scope)) - try: - check_wba_authorization(context) - except AdcpError as exc: - await JSONResponse(build_mcp_error_result(exc).model_dump(mode="json", by_alias=True))( - scope, receive, send - ) - return - dispatched.append(scope["adcp.server.signature_verified"]) - await JSONResponse({"ok": True})(scope, receive, send) - - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - enable_web_bot_auth=True, - wba_revocation_checker=not_revoked, - unbound_for=frozenset({operation}), - ) - app = SignedRequestVerificationMiddleware( - dispatch, config, transport="mcp", defer_authorization_errors=True - ) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: - for op in (operation, "create_media_buy", operation): - body = json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": op, "arguments": {}}, - } - ).encode() - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - response = await client.post(TARGET, content=body, headers=headers) - assert response.status_code == 200 - assert "Accept-Signature" not in response.headers - if op == "create_media_buy": - assert ( - response.json()["structuredContent"]["adcp_error"]["code"] - == "PERMISSION_DENIED" - ) - assert dispatched == [True, True] - - -@pytest.mark.parametrize("method", ["initialize", "tools/list"]) -async def test_unbound_policy_can_include_public_protocol_methods(method): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - - async def dispatch(scope, receive, send): - await JSONResponse({"ok": True})(scope, receive, send) - - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - enable_web_bot_auth=True, - wba_revocation_checker=not_revoked, - unbound_for=frozenset({method}), - ) - app = SignedRequestVerificationMiddleware( - dispatch, config, transport="mcp", defer_authorization_errors=True - ) - body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": {}}).encode() - headers = {"Content-Type": "application/json"} - headers.update( - sign_wba_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - private_key=private, - agent_origin=ORIGIN, - ) - ) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: - assert (await client.post(TARGET, content=body, headers=headers)).status_code == 200 diff --git a/tests/test_wba_outage_security.py b/tests/test_wba_outage_security.py deleted file mode 100644 index 1d0a92f25..000000000 --- a/tests/test_wba_outage_security.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Signer evidence outages never become anonymous or stale identity approval.""" - -from __future__ import annotations - -import asyncio -import json -from datetime import datetime, timedelta, timezone - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from adcp.signing.crypto import b64url_encode -from adcp.signing.revocation_fetcher import ( - AsyncCachingRevocationChecker, - CachingRevocationChecker, - FetchResult, - RevocationListFetchError, - RevocationListFreshnessError, -) -from adcp.signing.signer_keys import ResolvedSignerKey -from adcp.signing.wba import ( - DirectoryResponse, - DiscoveredSignerKeys, - OriginRevocationChecker, - WbaUnavailableError, - WbaVerificationError, - fetch_key_directory, - public_directory_key, - sign_key_directory, -) - -ORIGIN = "https://buyer.platform.com" - - -@pytest.mark.parametrize("status", [302, 401, 500, 404, 410]) -async def test_directory_fetch_preserves_error_status_without_reading_body(monkeypatch, status): - reads = [] - - class UnexpectedBody(httpx.AsyncByteStream): - async def __aiter__(self): - reads.append(True) - yield b"unexpected directory error body" - - transport = httpx.MockTransport( - lambda request: httpx.Response( - status, headers={"Content-Encoding": "gzip"}, stream=UnexpectedBody() - ) - ) - monkeypatch.setattr( - "adcp.signing.ip_pinned_transport.build_async_ip_pinned_transport", - lambda *args, **kwargs: transport, - ) - response = await fetch_key_directory(ORIGIN + "/.well-known/http-message-signatures-directory") - assert response.status == status - assert response.body == b"" - assert not reads - - -@pytest.mark.parametrize( - "outcome", - [ - "timeout", - "offline", - 204, - 301, - 302, - 304, - 307, - 308, - 400, - 401, - 403, - 408, - 429, - 500, - 503, - 404, - 410, - 200, - ], -) -async def test_directory_failure_classification_survives_cooldown(outcome): - calls = [] - - async def fetch(url, *, allow_private=False): - calls.append(url) - if outcome == "timeout": - raise TimeoutError("secret transport detail") - if outcome == "offline": - raise OSError("secret transport detail") - return DirectoryResponse(b"invalid", {}, status=outcome) - - resolver = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1000) - unavailable = outcome not in (200, 404, 410) - exceptions = [] - for _ in range(5): - with pytest.raises(WbaVerificationError) as caught: - await resolver("missing", signer_origin=ORIGIN) - assert isinstance(caught.value, WbaUnavailableError) == unavailable - assert caught.value.status == (503 if unavailable else 403) - assert bool(caught.value.headers.get("Accept-Signature")) == (not unavailable) - exceptions.append(caught.value) - assert len(calls) == 1 - assert len({id(error) for error in exceptions}) == len(exceptions) - metadata = resolver._failed_discoveries[ORIGIN].failure - assert metadata is not None - assert not isinstance(metadata, BaseException) - assert all(not isinstance(value, BaseException) for value in vars(metadata).values()) - - -async def test_fetched_directory_without_requested_key_is_absence_not_outage(): - private = Ed25519PrivateKey.generate() - response = sign_key_directory(agent_origin=ORIGIN, private_keys=[private], created=1000) - - async def fetch(url, *, allow_private=False): - return response - - resolver = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1000) - assert await resolver("missing", signer_origin=ORIGIN) is None - assert await resolver(public_directory_key(private)["kid"], signer_origin=ORIGIN) is not None - - -def revocation_fixture(*, private=None, start=None, revoked=False): - private = private or Ed25519PrivateKey.generate() - jwk = public_directory_key(private) - start = start or datetime(2026, 1, 1, tzinfo=timezone.utc) - payload = { - "version": 1, - "issuer": ORIGIN, - "updated": start.isoformat(), - "next_update": (start + timedelta(seconds=60)).isoformat(), - "revoked_kids": [jwk["kid"]] if revoked else [], - "revoked_jtis": [], - } - protected = b64url_encode( - json.dumps({"alg": "EdDSA", "kid": jwk["kid"], "typ": "adcp-gov-revocation+jws"}).encode() - ) - encoded = b64url_encode(json.dumps(payload).encode()) - signature = b64url_encode(private.sign(f"{protected}.{encoded}".encode())) - return jwk, start, FetchResult(f"{protected}.{encoded}.{signature}", etag=None) - - -@pytest.mark.parametrize("refresh_result", ["failed", "signed", "not_modified"]) -@pytest.mark.parametrize("asynchronous", [False, True]) -async def test_revocation_refresh_rechecks_grace_after_wait(refresh_result, asynchronous): - jwk, start, document = revocation_fixture() - now, calls = [0.0], [] - - def fetch(uri, **kwargs): - calls.append(uri) - if len(calls) == 1: - return document - # A request begins inside grace, but fetch completes at its boundary. - now[0] = 300.0 - if refresh_result == "failed": - raise RevocationListFetchError("offline") - if refresh_result == "not_modified": - return FetchResult("", etag=None, not_modified=True) - return document - - async def afetch(uri, **kwargs): - await asyncio.sleep(0) - return fetch(uri, **kwargs) - - async def resolve(keyid): - return jwk - - options = { - "issuer": ORIGIN, - "revocation_uri": ORIGIN + "/.well-known/governance-revocations.json", - "clock": lambda: now[0], - "wall_clock": lambda: start + timedelta(seconds=now[0]), - "grace_multiplier": 4, - } - if asynchronous: - checker = AsyncCachingRevocationChecker(fetcher=afetch, jwks_resolver=resolve, **options) - assert not await checker(jwk["kid"]) - now[0] = 299.0 - with pytest.raises(RevocationListFreshnessError): - await checker(jwk["kid"]) - else: - checker = CachingRevocationChecker( - fetcher=fetch, jwks_resolver=lambda keyid: jwk, **options - ) - assert not checker(jwk["kid"]) - now[0] = 299.0 - with pytest.raises(RevocationListFreshnessError): - checker(jwk["kid"]) - assert len(calls) == 2 - - -async def test_stale_authenticated_revocations_survive_failed_initial_origin_discovery(): - private = Ed25519PrivateKey.generate() - jwk, start, stale = revocation_fixture(private=private, revoked=True) - now, calls = [300.0], [] - - async def resolve(keyid, *, signer_origin=None): - return ResolvedSignerKey(signer_origin, jwk) - - async def fetch(uri, **kwargs): - calls.append(uri) - if len(calls) == 1: - return stale - _, _, fresh = revocation_fixture( - private=private, start=start + timedelta(seconds=now[0]), revoked=False - ) - return fresh - - checker = OriginRevocationChecker( - resolve, - fetcher=fetch, - clock=lambda: now[0], - wall_clock=lambda: start + timedelta(seconds=now[0]), - ) - with pytest.raises(WbaUnavailableError): - await checker(ORIGIN, jwk["kid"]) - assert ORIGIN in checker._checkers - assert checker._checkers[ORIGIN].has_verified_list - now[0] = 361 - assert await checker(ORIGIN, jwk["kid"]) - assert len(calls) == 2 diff --git a/tests/test_wba_prior_task_controls.py b/tests/test_wba_prior_task_controls.py deleted file mode 100644 index 03f0f1e84..000000000 --- a/tests/test_wba_prior_task_controls.py +++ /dev/null @@ -1,574 +0,0 @@ -"""Delisting preserves only the verified origin's previously owned A2A work.""" - -from __future__ import annotations - -import asyncio -import json -from typing import Any -from uuid import uuid4 - -import httpx -import pytest -from a2a import types as pb -from a2a.auth.user import User -from a2a.server.context import ServerCallContext -from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore -from asgi_lifespan import LifespanManager -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from adcp.server import ADCPHandler, RequestSignatureVerification, a2a_server -from adcp.server.auth import BearerTokenAuth, _A2AAuthenticatedUser -from adcp.server.serve import _build_mcp_and_a2a_app -from adcp.server.signed_requests import current_verified_signer, current_wba_authorization_error -from adcp.signing import ( - OriginBindings, - StaticSignerKeys, - TrustRecordEvaluator, - TrustResponse, - WbaUnavailableError, - public_directory_key, - sign_wba_request, -) -from adcp.signing.canonical import canonical_origin - -ORIGIN = "https://buyer.platform.com" -OTHER = "https://other.platform.com" - - -class OriginUser(User): - @property - def is_authenticated(self) -> bool: - return True - - @property - def user_name(self) -> str: - return ORIGIN - - -@pytest.fixture -async def task_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest): - monkeypatch.setenv("ADCP_SANDBOX", "1") - private = {origin: Ed25519PrivateKey.generate() for origin in (ORIGIN, OTHER)} - now, mode = [1000.0], ["listed"] - started, bearer_lookups, controlled = [], [], [] - release = asyncio.Event() - producers: set[asyncio.Task[Any]] = set() - - async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: - if mode[0] == "offline": - raise OSError("operator unavailable") - origins = (ORIGIN, OTHER) if mode[0] == "listed" else (OTHER,) - return TrustResponse( - json.dumps( - {"agents": [{"url": origin, "roles": ["adcp:buying"]} for origin in origins]} - ).encode() - ) - - async def not_revoked(origin: str, key_id: str) -> bool: - if store.signer_unavailable: - raise WbaUnavailableError("revocation status unavailable") - return False - - async def validate_token(token: str): - bearer_lookups.append(token) - return "bearer-principal" - - class Store(InMemoryTaskStore): - signer_unavailable = False - hide_binding = False - max_binding_reads: int | None = None - binding_reads: list[str] = [] - - async def get(self, task_id: str, context: ServerCallContext) -> pb.Task | None: - controlled.append((task_id, context.user.user_name)) - return await super().get(task_id, context) - - store = Store() - bindings = OriginBindings() - for origin in private: - bindings.bind(origin, "principal:" + origin) - - async def resolve_binding(origin: str) -> str | None: - store.binding_reads.append(origin) - if ( - store.max_binding_reads is not None - and len(store.binding_reads) > store.max_binding_reads - ): - raise TimeoutError("an unnecessary second binding read must not occur") - return None if store.hide_binding else await bindings(origin) - - class Handler(ADCPHandler[Any]): - advertised_tools = {"get_products", "get_principal"} - - async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: - started.append(context.metadata["adcp.auth_info"]) - await asyncio.wait_for(release.wait(), timeout=10) - return {"products": []} - - async def get_principal(self, params: Any, context: Any = None) -> dict[str, Any]: - return {"principal": context.metadata["adcp.auth_info"].extra["principal_id"]} - - # Long-running executors emit a working event before producing their result. - # Keep the real SDK ActiveTask lifecycle, persistence, and HTTP dispatch. - execute = a2a_server.ADCPAgentExecutor.execute - - async def working(self: Any, context: Any, queue: Any) -> None: - task = asyncio.current_task() - assert task is not None - producers.add(task) - signer = current_verified_signer.get() - long_running = ( - self._parse_request(context)[0] == "get_products" - and signer is not None - and signer.principal_id is not None - and current_wba_authorization_error.get() is None - ) - if long_running: - await queue.enqueue_event( - pb.Task( - id=context.task_id, - context_id=context.context_id, - status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_WORKING), - ) - ) - - class Updates: - async def enqueue_event(self, event: Any) -> None: - if isinstance(event, pb.Task): - for artifact in event.artifacts: - await queue.enqueue_event( - pb.TaskArtifactUpdateEvent( - task_id=event.id, context_id=event.context_id, artifact=artifact - ) - ) - await queue.enqueue_event( - pb.TaskStatusUpdateEvent( - task_id=event.id, context_id=event.context_id, status=event.status - ) - ) - else: - await queue.enqueue_event(event) - - try: - await execute(self, context, Updates() if long_running else queue) - finally: - producers.discard(task) - - monkeypatch.setattr(a2a_server.ADCPAgentExecutor, "execute", working) - build_card = a2a_server._build_agent_card - - def streaming_card(*args: Any, **kwargs: Any) -> pb.AgentCard: - card = build_card(*args, **kwargs) - card.capabilities.streaming = True - return card - - monkeypatch.setattr(a2a_server, "_build_agent_card", streaming_card) - app = _build_mcp_and_a2a_app( - Handler(), - name="prior-task-controls", - port=0, - host="127.0.0.1", - instructions=None, - test_controller=None, - task_store=store, - validation=None, - stateless_http=True, - allowed_hosts=["localhost"], - auth=BearerTokenAuth(validate_token=validate_token), - request_signature_verification=RequestSignatureVerification( - signer_keys=StaticSignerKeys( - {origin: {"keys": [public_directory_key(key)]} for origin, key in private.items()} - ), - enable_web_bot_auth=True, - origin_bindings=resolve_binding, - trust_records=TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]), - wba_revocation_checker=not_revoked, - unbound_for=frozenset({"get_adcp_capabilities", "get_principal"}) - | (frozenset({"GetTask"}) if getattr(request, "param", False) else frozenset()), - ), - ) - async with LifespanManager(app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app), base_url="http://localhost" - ) as client: - - async def post( - method: str, - params: dict[str, Any], - *, - origin: str = ORIGIN, - path: str = "/", - bearer: bool = False, - disconnect_after_first_event: bool = False, - preauthenticated_origin: str | None = None, - ) -> httpx.Response: - body = json.dumps( - {"jsonrpc": "2.0", "id": uuid4().hex, "method": method, "params": params} - ).encode() - headers = { - "host": "localhost", - "content-type": "application/json", - "accept": "application/json, text/event-stream", - } - if not method.startswith(("message/", "tasks/", "tools/")): - headers["A2A-Version"] = "1.0" - if bearer: - headers["authorization"] = "Bearer independent" - headers.update( - sign_wba_request( - method="POST", - url="http://localhost" + path, - headers=headers, - body=body, - private_key=private[canonical_origin(origin)], - agent_origin=origin, - ) - ) - if disconnect_after_first_event or preauthenticated_origin is not None: - delivered = False - disconnect = asyncio.Event() - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - nonlocal delivered - if not delivered: - delivered = True - return {"type": "http.request", "body": body, "more_body": False} - await asyncio.wait_for(disconnect.wait(), 2) - return {"type": "http.disconnect"} - - async def send(message: dict[str, Any]) -> None: - sent.append(message) - if b"data:" in message.get("body", b"") or ( - message["type"] == "http.response.body" - and not message.get("more_body", False) - ): - disconnect.set() - - scope = { - "type": "http", - "asgi": {"version": "3.0", "spec_version": "2.3"}, - "http_version": "1.1", - "method": "POST", - "scheme": "http", - "path": path, - "raw_path": path.encode(), - "query_string": b"", - "root_path": "", - "server": ("localhost", 80), - "client": ("127.0.0.1", 50000), - "headers": [ - (key.lower().encode(), value.encode()) for key, value in headers.items() - ], - } - if preauthenticated_origin is not None: - scope["user"] = _A2AAuthenticatedUser(display_name=preauthenticated_origin) - scope["auth"] = {"caller_identity": preauthenticated_origin} - await asyncio.wait_for(app(scope, receive, send), 5) - assert disconnect.is_set(), sent - start_message = next( - item for item in sent if item["type"] == "http.response.start" - ) - return httpx.Response( - start_message["status"], - headers=start_message["headers"], - content=b"".join(item.get("body", b"") for item in sent), - ) - return await asyncio.wait_for(client.post(path, content=body, headers=headers), 5) - - async def start(origin: str = ORIGIN) -> str: - response = await post( - "message/send", - { - "message": { - "messageId": uuid4().hex, - "role": "user", - "parts": [ - { - "kind": "data", - "data": {"skill": "get_products", "parameters": {}}, - } - ], - }, - "configuration": {"blocking": False}, - }, - origin=origin, - ) - assert response.status_code == 200, response.text - task = response.json()["result"] - assert task["status"]["state"] == "working", response.text - return task["id"] - - try: - yield post, start, store, now, mode, started, bearer_lookups, controlled - finally: - release.set() - if producers: - await asyncio.wait_for(asyncio.gather(*producers, return_exceptions=True), 2) - await asyncio.sleep(0) - - -def assert_not_found(response: httpx.Response) -> None: - assert response.status_code == 200, response.text - if response.headers.get("content-type", "").startswith("text/event-stream"): - events = [ - json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ") - ] - assert len(events) == 1, response.text - wire = events[0] - else: - wire = response.json() - # The installed legacy adapter maps native TaskNotFound to its generic - # JSON-RPC error; its message and missing/foreign behavior remain identical. - assert wire["error"]["code"] in {-32001, -32603}, response.text - assert wire["error"]["message"] == "Task not found", response.text - assert "accept-signature" not in response.headers - - -@pytest.mark.parametrize("get,cancel", [("tasks/get", "tasks/cancel"), ("GetTask", "CancelTask")]) -async def test_delisted_origin_can_poll_cancel_its_active_task_without_new_authority( - task_env, get, cancel -): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - mode[0], now[0] = "delisted", 1061 - polled = await post(get, {"id": task_id}, origin="https://BUYER.platform.com:443", bearer=True) - assert polled.status_code == 200, polled.text - assert polled.json()["result"]["id"] == task_id - assert controlled[-1] == (task_id, ORIGIN) - assert bearer == [] - canceled = await post(cancel, {"id": task_id}) - assert canceled.status_code == 200, canceled.text - assert "canceled" in canceled.json()["result"]["status"]["state"].lower() - assert len(started) == 1 - # Cancellation must persist under the old origin rather than migrating the row. - assert await store.get(task_id, ServerCallContext()) is None - denied = await post( - "message/send", - { - "message": { - "messageId": "new", - "role": "user", - "parts": [{"kind": "data", "data": {"skill": "get_products", "parameters": {}}}], - } - }, - ) - assert denied.json()["result"]["status"]["state"] == "failed", denied.text - assert len(started) == 1 - - -@pytest.mark.parametrize( - "method", ["CancelTask", "SubscribeToTask", "tasks/cancel", "tasks/resubscribe"] -) -@pytest.mark.parametrize("delisted", [False, True]) -async def test_active_tasks_cannot_be_controlled_by_another_origin(task_env, method, delisted): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start(OTHER) - if delisted: - mode[0], now[0] = "delisted", 1061 - # Even duplicate IDs in two owner-scoped partitions must not let the - # globally indexed ActiveTask of OTHER be canceled/subscribed by ORIGIN. - await store.save( - pb.Task( - id=task_id, - context_id="duplicate", - status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_WORKING), - ), - ServerCallContext(user=OriginUser()), - ) - assert_not_found(await post(method, {"id": task_id})) - own = await post("GetTask", {"id": task_id}, origin=OTHER) - assert own.json()["result"]["status"]["state"] == "TASK_STATE_WORKING", own.text - - -@pytest.mark.parametrize("method", ["GetTask", "CancelTask", "SubscribeToTask"]) -async def test_delisted_task_control_never_promotes_anonymous_tasks(task_env, method): - post, start, store, now, mode, started, bearer, controlled = task_env - await start() - await store.save( - pb.Task( - id="anonymous", - context_id="anonymous", - status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_WORKING), - ), - ServerCallContext(), - ) - mode[0], now[0] = "delisted", 1061 - assert_not_found(await post(method, {"id": "anonymous"})) - - -async def test_delisted_task_listing_is_not_admitted(task_env): - post, start, store, now, mode, started, bearer, controlled = task_env - await start() - mode[0], now[0] = "delisted", 1061 - response = await post("ListTasks", {}) - assert response.status_code == 401 - - -@pytest.mark.parametrize("method", ["tasks/get", "tasks/cancel"]) -async def test_mcp_prior_task_controls_retain_normal_unsupported_method_response(task_env, method): - post, start, store, now, mode, started, bearer, controlled = task_env - await start() - mode[0], now[0] = "delisted", 1061 - response = await post(method, {"taskId": "existing"}, path="/mcp") - assert response.status_code == 200, response.text - assert response.json()["error"]["code"] == -32601, response.text - assert len(started) == 1 - - -@pytest.mark.parametrize("method", ["SubscribeToTask", "tasks/resubscribe"]) -async def test_delisted_origin_can_subscribe_to_its_existing_active_task(task_env, method): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - mode[0], now[0] = "delisted", 1061 - subscription = asyncio.create_task(post(method, {"id": task_id})) - try: - # The store lookup proves subscription reached the owner guard before - # ending the active task. Waits are bounded to expose deadlocks clearly. - async def subscribed() -> None: - while (task_id, ORIGIN) not in controlled: - await asyncio.sleep(0) - - controlled.clear() - await asyncio.wait_for(subscribed(), 2) - canceled = await post("CancelTask", {"id": task_id}) - assert canceled.status_code == 200, canceled.text - response = await asyncio.wait_for(subscription, 5) - assert response.status_code == 200, response.text - assert task_id in response.text - assert "canceled" in response.text.lower() - finally: - if not subscription.done(): - subscription.cancel() - await asyncio.gather(subscription, return_exceptions=True) - - -@pytest.mark.parametrize("method", ["CancelTask", "SubscribeToTask"]) -async def test_registry_replacement_is_checked_on_the_exact_task_used( - task_env, method, monkeypatch: pytest.MonkeyPatch -): - from a2a.server.agent_execution.active_task_registry import ActiveTaskRegistry - - post, start, store, now, mode, started, bearer, controlled = task_env - own_id, foreign_id = await start(), await start(OTHER) - get_or_create = ActiveTaskRegistry.get_or_create - - async def replaced(self: Any, task_id: str, *args: Any, **kwargs: Any): - # Simulate cleanup/replacement after the persisted owner check. A - # second lookup could otherwise select a foreign ActiveTask. - return await get_or_create( - self, foreign_id if task_id == own_id else task_id, *args, **kwargs - ) - - monkeypatch.setattr(ActiveTaskRegistry, "get_or_create", replaced) - assert_not_found(await post(method, {"id": own_id})) - foreign = await post("GetTask", {"id": foreign_id}, origin=OTHER) - assert foreign.json()["result"]["status"]["state"] == "TASK_STATE_WORKING" - - -@pytest.mark.parametrize("method", ["GetTask", "CancelTask", "SubscribeToTask"]) -async def test_signer_outage_never_inherits_the_delisted_task_owner(task_env, method): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - mode[0], now[0] = "delisted", 1061 - assert (await post("GetTask", {"id": task_id})).status_code == 200 - store.signer_unavailable = True - controlled.clear() - response = await post(method, {"id": task_id}, bearer=True) - assert response.status_code == 503, response.text - assert response.json()["errors"][0]["code"] == "SERVICE_UNAVAILABLE" - assert "accept-signature" not in response.headers - assert controlled == [] - assert bearer == [] - assert len(started) == 1 - - -async def test_task_exception_keeps_principal_introspection_demoted_during_later_outage(task_env): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - mode[0], now[0] = "delisted", 1061 - assert (await post("GetTask", {"id": task_id})).status_code == 200 - mode[0], now[0] = "offline", 1122 - assert (await post("GetTask", {"id": task_id})).json()["result"]["id"] == task_id - response = await post( - "message/send", - { - "message": { - "messageId": "principal", - "role": "user", - "parts": [{"kind": "data", "data": {"skill": "get_principal", "parameters": {}}}], - } - }, - ) - assert response.json()["result"]["artifacts"][0]["parts"][0]["data"]["principal"] is None - - -async def test_new_message_cannot_retarget_a_foreign_active_task(task_env): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start(OTHER) - await store.save( - pb.Task( - id=task_id, - context_id="duplicate", - status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_WORKING), - ), - ServerCallContext(user=OriginUser()), - ) - response = await post( - "message/send", - { - "message": { - "messageId": "continuation", - "taskId": task_id, - "role": "user", - "parts": [{"kind": "data", "data": {"skill": "get_products", "parameters": {}}}], - }, - "configuration": {"blocking": False}, - }, - ) - assert_not_found(response) - assert len(started) == 1 - foreign = await post("GetTask", {"id": task_id}, origin=OTHER) - assert foreign.json()["result"]["status"]["state"] == "TASK_STATE_WORKING" - - -@pytest.mark.parametrize("method", ["SubscribeToTask", "tasks/resubscribe"]) -async def test_real_client_disconnect_ends_only_the_owned_subscription(task_env, method): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - mode[0], now[0] = "delisted", 1061 - response = await post(method, {"id": task_id}, disconnect_after_first_event=True) - assert response.status_code == 200, response.text - assert task_id in response.text - assert "working" in response.text.lower() - still_working = await post("GetTask", {"id": task_id}) - assert still_working.json()["result"]["status"]["state"] == "TASK_STATE_WORKING" - - -async def test_delisted_task_owner_supersedes_an_outer_authenticated_user(task_env): - post, start, store, now, mode, started, bearer, controlled = task_env - own_id, foreign_id = await start(), await start(OTHER) - mode[0], now[0] = "delisted", 1061 - own = await post("GetTask", {"id": own_id}, preauthenticated_origin=OTHER) - assert own.json()["result"]["id"] == own_id - assert_not_found(await post("GetTask", {"id": foreign_id}, preauthenticated_origin=OTHER)) - assert bearer == [] - - -async def test_prior_task_control_uses_the_single_evaluated_binding_decision(task_env): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - mode[0], now[0] = "delisted", 1061 - store.max_binding_reads = len(store.binding_reads) + 1 - response = await post("GetTask", {"id": task_id}) - assert response.json()["result"]["id"] == task_id - assert len(store.binding_reads) == store.max_binding_reads - - -@pytest.mark.parametrize("task_env", [True], indirect=True) -async def test_explicit_anonymous_task_policy_does_not_recover_an_unbound_origin_owner(task_env): - post, start, store, now, mode, started, bearer, controlled = task_env - task_id = await start() - store.hide_binding = True - assert_not_found(await post("GetTask", {"id": task_id})) - assert controlled[-1] == (task_id, "") diff --git a/tests/type_checks/experimental_identity.py b/tests/type_checks/experimental_identity.py deleted file mode 100644 index 3e9b1e70d..000000000 --- a/tests/type_checks/experimental_identity.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Draft response readers retain their field types under mypy and pyright.""" - -from __future__ import annotations - -from typing import Literal - -from typing_extensions import assert_type - -from adcp.server.signed_requests import RequestSignatureVerification -from adcp.signing import ( - DiscoveredSignerKeys, - ResolvedSignerKey, - SignerKeyResolver, - WbaSignerKeyResolver, -) -from adcp.types.domains.core.principal_changed_webhook import Reason -from adcp.types.domains.protocol.get_principal_response import PrincipalReadFailedResult -from adcp.types.experimental_identity import ( - AccountCapabilities, - AgentOnboardingRequirement, - AgentOnboardingRequirementKind, - GetAdcpCapabilitiesResponse, - GetPrincipalResponse, - PrincipalChangedWebhook, - PrincipalCurrentResult, - PrincipalRecognizedResult, - PrincipalRelationship, - PrincipalUnconfiguredResult, - RequestSigning, -) - - -def principal_relationship(response: GetPrincipalResponse) -> PrincipalRelationship | None: - assert_type( - response.result, - PrincipalUnconfiguredResult - | PrincipalRecognizedResult - | PrincipalCurrentResult - | PrincipalReadFailedResult, - ) - if isinstance(response.result, PrincipalReadFailedResult): - return None - assert_type(response.result.relationship, PrincipalRelationship | None) - return response.result.relationship - - -def onboarding_message(response: GetAdcpCapabilitiesResponse) -> str | None: - assert_type(response.account, AccountCapabilities | None) - assert_type(response.request_signing, RequestSigning | None) - if response.request_signing is not None: - assert_type(response.request_signing.profiles, list[str] | None) - if response.account is None or response.account.agent_onboarding is None: - return None - return response.account.agent_onboarding.message - - -def requirement_kind(requirement: AgentOnboardingRequirement) -> str: - assert_type(requirement.kind, AgentOnboardingRequirementKind | str) - return requirement.kind - - -def webhook_reason(event: PrincipalChangedWebhook) -> str: - assert_type(event.reason, Reason | Literal["relationship_changed", "relationship_expiring"]) - return event.reason - - -class LegacyKeyResolver: - """Existing adopter resolvers need only the original keyid argument.""" - - async def __call__(self, keyid: str) -> ResolvedSignerKey | None: - return None - - -def legacy_resolver_configuration() -> RequestSignatureVerification: - legacy: SignerKeyResolver = LegacyKeyResolver() - discovered = DiscoveredSignerKeys(legacy_resolver=legacy) - origin_aware: WbaSignerKeyResolver = discovered - assert isinstance(origin_aware, WbaSignerKeyResolver) - return RequestSignatureVerification(signer_keys=legacy)