diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 89419db73..e85c64d5c 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -2,6 +2,8 @@ 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. +The behavior below follows the final spec decisions (adcontextprotocol/adcp#8162, #8168, #8169, #8170) and matches the Go (`adcp-go`) and TypeScript (`@adcp/sdk/identity-3.3`) SDKs, except where [Differences from the other SDKs](#differences-from-the-other-sdks) says otherwise. + ## 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: @@ -21,9 +23,13 @@ signing = SigningConfig( # 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. +`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 64-byte random nonce (unpadded base64url; RECOMMENDED by the architecture draft §4.2.2 and required by Cloudflare's `web-bot-auth` verifier). A caller-supplied `nonce` may be any 22 to 256 characters, the range verifiers accept, and binds every nonempty body with its digest. `@target-uri` covers the URI the request is sent to, unchanged: percent-encoding such as `/a%7Eb/mcp/` is signed as sent (RFC 9421 §2.2.2). The canonical origin is used only for the `Signature-Agent` identity. A request that already carries signature fields is refused rather than stripped; the signer never drops another party's signature. + +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. Keep header emission opt-in until all counterparties tolerate the additional covered component. + +Ed25519 and P-256 keys work everywhere. `sign_wba_request`, `public_directory_key` and `sign_key_directory` also accept RSA keys of 2048 to 8192 bits, which sign with `rsa-pss-sha512` as in the draft's Appendix E vectors. -Serve a signed directory response at `/.well-known/http-message-signatures-directory` on that origin: +Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. Signing it is RECOMMENDED (WBA Appendix B.1): ```python response = sign_key_directory( @@ -33,9 +39,13 @@ response = sign_key_directory( # 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. +The helper signs once per key, covering `"@authority";req` and `content-digest`. It backdates `created` by five minutes, because verifiers allow little clock skew on directory signatures, and the signatures still expire `expires_in_seconds` from now. Recreate and republish the response before its signatures expire. Publish new keys before using them; verifiers cache directories for up to five minutes. A publisher must not expose private key material in the directory. + +Serve a signed revocation list at `/.well-known/governance-revocations.json` on the same origin (`adcp-gov-revocation+jws`, signed by a directory key), even when no key is revoked. **Without one, sellers treat your origin as identity-established and won't bind it for spend.** While they can't read it, or it is stale past grace, your spend-committing calls get `SERVICE_UNAVAILABLE`; other calls proceed. + +List the origin in `https://{your registrable domain}/.well-known/trust.json` with `adcp:buying`. `validate_trust_record(document, operator_domain=...)` validates it before you publish. -## Seller verification and binding +## Seller verification ```python from adcp.server import RequestSignatureVerification, serve @@ -58,57 +68,122 @@ verification = RequestSignatureVerification( 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. +Set `authorities=frozenset({"seller.example.com"})` to the `host[:port]` values the endpoint serves: a Web Bot Auth request whose `@authority` is not among them is refused (403) before key discovery, so a signature captured at another seller cannot be replayed here under that seller's `Host`. `serve` logs a warning at boot when Web Bot Auth is enabled without it. -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. +A presented `Signature-Agent` or `web-bot-auth` tag selects WBA exclusively, including when verification is in shadow mode. A seller that has not enabled Web Bot Auth answers such a request 403 without `Accept-Signature`; it never falls back to the RFC 9421 profile. `signer_keys` must resolve by origin (its `__call__` accepts `signer_origin=`), which `DiscoveredSignerKeys` does; construction raises `TypeError` otherwise. -`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. +### Layers -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. +Verification and authorization are separate layers, as in the other SDKs. -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. +- **Verification** (`verify_wba_request`) needs only the key directory and a replay store. It reads no revocation list and no trust record, and grants no authority, so it can also run in front of the server. `revocation_checker` is optional there; when given, the revocation check runs after verification. +- **Authorization** runs after it: revocation (`check_wba_revocation`), then one injected `WbaAuthorizer` (`adcp.server.wba_authorization`), an async `(signer, operation, params) -> principal | None` that raises an `AdcpError` for an authorization outcome. The middleware never reads a binding or a trust record itself. The default, `OriginBindingGate`, is built from `origin_bindings`, `trust_records`, `binding_role` and `spend_classifier`; pass `RequestSignatureVerification(wba_authorizer=...)` to keep binding and trust evaluation in your application. Grants run later, in the account gate. -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: +### Errors are split by layer -```python -await records.bind( - verified_signer, - principal_id="principal_123", - bindings=bindings, -) -``` +- **The signature path** answers only with Web Bot Auth statuses and headers, never 503 (DR-0023): 400 when a signature field does not parse, 403 with `Accept-Signature` for a missing or failed signature or an unresolvable key directory, 403 without `Accept-Signature` for a revoked key, and 429 for a reused nonce and for replay-store or discovery capacity. `RequestSignatureVerification(wba_replay_status=403)` (or `verify_wba_request(replay_status=403)`) only reproduces #7894's older replay vector. Any body is the one generic authentication error, the same for every cause: `{"errors":[{"code":"AUTH_INVALID","message":"Request authentication failed","recovery":"correctable"}]}`. The specific reason is logged, never sent. +- **Authorization outcomes** after verification use the standard AdCP error envelope (the MCP and A2A error bindings for skills; otherwise JSON with HTTP 403 or 503 and `Retry-After`) with existing codes: + - an operation outside what an unbound caller may do gets what an anonymous caller would get; + - an operation in `required_for` from an unbound or delisted origin, or a failed binding, gets `PERMISSION_DENIED` with no `details`; + - a missing grant gets `PERMISSION_DENIED` with `{"scope": "agent", "reason": "grant_absent"}`; + - a trust-record or revocation-list outage on a spend-committing call gets the retryable `SERVICE_UNAVAILABLE`. +- **Infrastructure outages** (the replay store or revocation store failing) are application-layer `SERVICE_UNAVAILABLE`, never a signature outcome. + +A correctly signed request whose key the verifier can resolve never gets 403 with `Accept-Signature` (an unresolvable key directory leaves the request unverified under the default policy). Clients act on the status and `Accept-Signature`, not the generic body: sign again on 403 with `Accept-Signature`, stop using the key on 403 without it, back off on 429 or 503. -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. +**Unsigned requests.** With Web Bot Auth enabled, an unsigned request to an operation in `required_for` is answered as the RFC 9421 profile does (401 with `WWW-Authenticate: Signature error="request_signature_required"`) plus an `Accept-Signature` header for Web Bot Auth, so a client of either profile learns how to sign. Set `rfc9421_profile=False` when the endpoint advertises only `web-bot-auth`: such a request, and any RFC 9421 signature, then gets 403 with `Accept-Signature`. `RequestSignatureVerification.from_capability` sets both from `request_signing.profiles` when it lists `web-bot-auth`. With `allow_bearer_fallback`, an unsigned request is passed to bearer authentication as before. -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. +Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` labels (for example `created=1;created=2`) 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. -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. +### Interop -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. +- The verifier accepts any nonce of 22 to 256 printable characters (at least 128 bits in base64): 64 bytes is RECOMMENDED for signers, and httpsig-02 adds no nonce requirement, so a compliant signer may send fewer. This SDK's signer always sends 64 bytes, which Cloudflare's verifier requires. +- `@target-uri` is the target URI as received, built from the request line (`raw_path` and query string), so percent-encoding is preserved. `@authority` is that URI's authority, lowercased with any default port removed (RFC 9421 §2.2.3). +- RSA keys verify with `rsa-pss-sha512` (2048 to 8192 bits, odd exponent). +- `tests/conformance/vectors/wba-interop/` vendors adcp-client's Cloudflare interop fixtures with provenance; the SDK verifies both the Cloudflare-signed and the adcp-client-signed requests and directories. -`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. +### Key directories -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. +`DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. -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. +- A plain, unsigned JWK Set is accepted by default (`application/http-message-signatures-directory+json`, `application/jwk-set+json` or `application/json`). Keys are found by their RFC 7638 thumbprint; a `kid` is optional and does not affect lookup. Keys the profile cannot use are skipped; private key material fails the directory. `DiscoveredSignerKeys(require_signed_directories=True)` refuses unsigned directories. +- When a directory carries signatures, every one must verify, cover at least the exact identifiers `"@authority";req` and `"content-digest"` (a keyed member or a bare `"@authority"` does not count), and fall inside its window (60 seconds of skew on `created`, none on `expires`). The digest covers the exact bytes served, a UTF-8 BOM included. Only keys with a verified signature are usable; an unsigned key beside them is ignored. +- **Discovery failures** (WBA §6.10) cover a directory that does not resolve: a network or TLS error, a timeout, any status other than 200 (404, 410, 429 and 5xx included), or an invalid directory. A discovery failure never evicts a cached directory: its keys keep verifying for up to `max_stale_seconds` (24 hours by default) after the last successful fetch, never past the directory signatures' expiry. A key missing from such a stale copy is a discovery failure too. Failures are retried after a 60-second cooldown. +- With no usable cached copy, the request is unverified, and what happens is local policy: `RequestSignatureVerification(wba_discovery_failure=...)`. The default `"reject"` answers 403 with `Accept-Signature`. `"anonymous"` serves the request exactly as an unsigned one, except operations in `required_for` or `protocol_methods_required_for`. +- A directory that resolves without the key is evidence that the key was removed: a signature failure. +- Successful directories are an LRU cache of `max_origins` (256) origins; eviction only forces a refetch. Concurrent discoveries are bounded globally and per operator (`max_origins_per_operator`, 8). A refusal by these local limits is load shedding: 429, and a cached copy keeps verifying for one more five-minute period only. -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. +The operator quota groups use a registrable domain, except that every tenant of a PSL PRIVATE hosting suffix (such as `*.vercel.app` or `*.github.io`) counts under the provider's registrable domain (`adcp.signing.etld.quota_key`). Authority itself still treats each tenant as its own operator. -## Trust records and demand grants +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. -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. +### Revocation -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. +Framework WBA verification installs `OriginRevocationChecker` by default. It reads each origin's signed list and reports, through `status(origin, keyid)`: -`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. +- **`"revoked"`:** refused with 403 and no `Accept-Signature`. A stale list still revokes everything it revoked, and revocations are never withdrawn. +- **`"current"`:** a list within `next_update` plus grace (four polling intervals) does not revoke the key. +- **`"no_list"`:** the origin answers 404/410 and no list was ever verified. The identity is established but cannot be bound: `TrustRecordEvaluator.bind` refuses it, and the binding gate treats a bound origin with no list as identity-established. +- **`"stale"`:** the list cannot be read or verified, is past grace (a stale first list included), or its revocations exceed the checker's fact limits. Spend-committing calls get `SERVICE_UNAVAILABLE` from the binding and grant gates; other calls proceed. A later 404 after a verified list is a failure to read it, never a reset. +- **`"unavailable"`:** too many revocation discoveries are in flight to check now, or a custom checker's store cannot be read: every bound call gets `SERVICE_UNAVAILABLE`. + +The result is recorded in `VerifiedSigner.revocation` and in `context.metadata["adcp.auth_info"].extra["wba_revocation"]`. A custom `wba_revocation_checker` can implement `status()`, or be a legacy async callable `(origin, keyid) -> bool` whose failure counts as `"stale"`. Concurrent checks for one origin share one fetch. Both checker variants keep `keyCompromise` and the earliest `invalidity_date`; an undated compromise stays undated. `RevocationDetails.exposes(received_at)` uses seller receipt time to assess compromise exposure. + +Per-origin list state is a least-recently-used cache of `max_origins` (256) origins and `max_origins_per_operator` (8) per quota group, so a flood of attacker origins cannot lock new buyers out; evicting it never forgets a revocation. Revoked key facts are kept separately with non-evicting limits: `max_facts_per_origin=2048`, `max_facts_per_operator=8192` (per quota group) and `max_history_facts=65536`. Overflow refuses the offending origin (it stays `"stale"`) without forgetting another origin's revocations. This history is in memory: it is lost on restart and not shared across replicas. The checker has no pluggable durable store yet; public deployments that need revocations to hold across restarts should supply their own `wba_revocation_checker` with durable state. + +## Identity-established callers and binding + +A verified origin with no binding is identity-established. It gets what an anonymous caller gets: + +- Operations in `unbound_for` (default `get_adcp_capabilities` and `get_principal`) run with the verified identity attached. `get_principal` is always allowed, and on MCP so are the session methods (`initialize`, `notifications/initialized`, `ping`, `tools/list`). +- Any other operation is served exactly as an anonymous request, with no verified signer, so your application answers it as it answers anonymous callers (for example, bearer authentication still applies). An operation in `required_for` or `protocol_methods_required_for` is refused with `PERMISSION_DENIED`, even when `allow_bearer_fallback` is set and a valid bearer token accompanies the signature (as in Go): a caller that presents a signature is judged by it. +- Include `sync_principal` in `unbound_for` only when the application implements an explicit open onboarding policy that checks trust, requirements and billing before approval. `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) +``` -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. +The signer must carry a current revocation state (`verified_signer.revocation == "current"`; the framework's verification sets it), and the record must currently 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 (no downgrade). Persist bindings and the origin's commercial identity in the seller's registry; the registry still governs suspension, billing and account access. -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. +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"]`. The verified signer is read per request from the request scope, so a stateful MCP session never attributes one request's signer to another. + +### Delisting and trust-record outages + +A stored binding records seller approval, not permanent operator membership. The binding gate re-evaluates the operator's agent entry through `trust_records` on every request; its cache is bounded by the polling interval. `binding_role` defaults to `adcp:buying`. + +- **Delisted** (the last record read does not list the origin with the role, or the operator never published a record): the origin falls back to identity-established, exactly as at first contact, with no exception: task polling and cancelling included. The binding is kept, so the origin still cannot downgrade to RFC 9421, and `bind` refuses to bind it again while it is unlisted. Operators retire an agent by publishing an empty `agents[]`. +- **Unreachable or unreadable** (a transport error, any status other than 200 including a 404/410 after the operator published a record, an invalid record, or a local capacity refusal): a discovery failure, not a delisting and not a removal. An outage never restores access. + - The last record read stays authoritative. If it did not list the origin, the origin stays delisted whatever made the refresh fail. + - If it did, spend-committing calls fail with `SERVICE_UNAVAILABLE`. Other calls continue under the binding while that record is within `TrustRecordEvaluator(outage_continuation_seconds=...)` past its freshness. That bound is seller policy (default four polling intervals; `0` disables continuation). After it, they also get `SERVICE_UNAVAILABLE` until a fetch succeeds. + - With no record in the authority history, outages are `SERVICE_UNAVAILABLE`. +- A 404/410 from an operator that has never published a record means no record: the origin is not listed. `TrustRecordEvaluator.check_listing` implements these rules. + +`RequestSignatureVerification.spend_classifier(operation, params)` lets a seller classify spending using its own state. The default fails closed: new purchases, acquisitions, `activate_signal` and unknown operations commit spend, and so does every `update_media_buy` or `control_media_buy` unless each change is a rename, a pause (`paused: true`) or a cancellation, at the buy or package level. Budgets, bids, impressions, flight dates, pacing, spend targets, new packages, resumes and unknown fields all count as spend. A seller that can compare a change with its own state (for example, a budget reduction) supplies its own classifier. The 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` answers them itself with the AdCP envelope 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. 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. + +## Trust records + +`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 polling interval (default 60 seconds). Negative results are cached for 60 seconds. + +- **Host budgets.** Fetches to an operator's own hosts are limited per `(operator, host)` (`max_fetches_per_host`, 4). `authoritative_location` hops to hosts outside the operator's registrable domain share a separate global per-host budget (`max_foreign_fetches_per_host`, 16), so a hop never spends the target operator's own budget and many operators cannot multiply fetches to one host. Host cool-downs are scoped to `(operator, host)` and never follow content errors. A budget refusal is a retryable outage that is never cached as a failure. +- **Authority history** (which operators have served a 200, and the last record read from each) is security state. It is never evicted. The in-memory default admits at most `max_history_records` (65,536) operators and `max_history_records_per_operator` (64) per quota group (PSL PRIVATE tenants count under their provider). A new operator over quota is refused as unavailable before fetching, and no other operator's history is lost. Losing this history (a restart, another replica) turns a later 404 into absence, re-enabling brand.json fallback, or turns a delisting into an outage. The evaluator has no pluggable durable history store yet; until it does, run it in one long-lived process per seller, or treat brand.json fallback as unavailable for houses you have seen publish. `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. +## Demand grants + +`TrustGrantEvaluator.check(...)` follows L2 §Grants in trust.json: + +- **One record authorizes: the house's.** Pass `resolve_house=`, an async `(brand_domain, brand_id) -> BrandHouse | str | None`, returning the house named by the brand's verified brand.json (a house redirect, or a `house_domain` the house's `brand_refs[]` reciprocates). `BrandHouse(domain, brand_id)` also gives the brand's child-brand id in the house's portfolio, which grants' `profiles.adcp.brands` are checked against (as in TypeScript). Without the hook, or when it returns `None`, the brand's own domain is the house. A resolver failure is `SERVICE_UNAVAILABLE`. Grants in any other record are never consulted. +- **Domains compare as registrable domains** under the pinned PSL snapshot, the `grantee` included (a grantee of `ads.platform.com` matches `platform.com`; `validate_trust_record` still asks authors to publish registrable domains). Authority is implicit when `account.operator`, the brand's domain and the signer's operator share one. +- **Otherwise a chain must reach the signer's operator with `account.operator` on it:** the house grants the signer's operator, which is `account.operator`; the brand is `account.operator` (a self-operated brand) and its house grants the signer's operator; or the house grants `account.operator`, whose own record grants the signer's operator. There is no third link. +- **Every link must cover** the scope, `profiles.adcp.brands`, the countries and the agent narrowing (a narrowed link that does not list the signer's canonical origin ends the chain), and the check time must fall inside every link's `valid_from`/`valid_until`, so the effective window is the latest `valid_from` to the earliest `valid_until`. +- `exclusive_grants` makes `grants[]` exhaustive for the scopes it names, disabling `seller_authorization` for them. +- **No downgrade.** A 200 from the house's trust.json makes it the only source of grants: `legacy_authorization` (brand.json `authorized_operators`) runs only while the house has never published one. +- **Fetch failures.** A grantor or intermediary record that can't be read, including a 404/410 after it was read, is never absence and never `grant_absent`. A claim marked non-spend (`allow_stale_on_outage=True`) is evaluated on the last records read within the outage-continuation bound; anything else gets `SERVICE_UNAVAILABLE`. An intermediary that has never published a record provides no second link. For `DecisioningPlatform`, wire a gate and an active buyer-agent registry: @@ -118,7 +193,7 @@ from adcp.signing import TrustGrantEvaluator # Extract existing-account brand/operator/countries from seller-owned state. gate = TrustGrantGate( - evaluator=TrustGrantEvaluator(records), + evaluator=TrustGrantEvaluator(records, resolve_house=resolve_verified_house), extract_claim=lambda account, agent: TrustGrantClaim( brand_domain=account.metadata["brand_domain"], account_operator=account.metadata["operator"], @@ -130,32 +205,63 @@ gate = TrustGrantGate( # 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. +- The gate checks every natural-key `sync_accounts` declaration before any upsert. A row that names an existing `account_id` is checked against that account's seller-owned claim, and its brand/operator fields are checked too; an `account_id` that does not resolve is denied. +- Existing accounts and account-scoped calls use the seller's extractor, including a fresh check before spending. An extractor that raises or returns something other than a `TrustGrantClaim`, or an operation outside media buying whose claim keeps the default scope, fails closed with `PERMISSION_DENIED` without details; a claim without a brand or operator is denied the same way. `grant_absent` is reserved for a claim no grant covers. +- `TrustGrantClaim.scope` defaults to `adcp:media_buying` for buying, `list_products` and account provisioning. For other domains, configure `extract_operation_claim(account, buyer_agent, operation)` to select an explicit scope. +- A missing grant raises `PERMISSION_DENIED` with `{"scope": "agent", "reason": "grant_absent"}` after commercial identity is established. A signer its own operator does not list gets `PERMISSION_DENIED` without details; an outage requiring fresh authority gets the retryable `SERVICE_UNAVAILABLE`. A stale revocation list fails spend closed at the gate too. Provisioning always requires fresh authority. The gate changes no account status. +- The gate keeps no claim history of its own: the evaluator's last record read is authoritative, so a withdrawn grant stays withdrawn through a later outage. + +When configured, this gate replaces the older `brand_authorization_gate` for account-scoped dispatch. Put any permitted brand.json fallback in `TrustGrantEvaluator(legacy_authorization=...)`. Legacy RFC 9421 and bearer buyers can use it without an operator trust record while the house has never published trust.json. 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. +Typed client results preserve `adcp_error`, including its code, recovery guidance, and details. -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. +## Preview types and rollout -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. +Use the preview `RequestSigning` model (`adcp.types.experimental_identity`) 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. -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. +The schemas follow #8170 and #8168: the buyer posture lives only in trust.json, at `profiles.adcp.onboarding` on the record and on an agent entry; one mode enum (`open`, `application`, `invite_only`) serves sellers and buyers; the `agent_identity` requirement kind and `documentation_url` are gone; `aao_verified` uses `verified_specialisms`; `relationship.status` uses `pending_approval`. -`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. +Import experimental models from `adcp.types.experimental_identity`: `AgentOnboarding`, its modes/tiers/requirements, `BuyerOnboardingPosture`, `TrustRecord`, `GetPrincipalResponse`, `PrincipalRelationship`, `PrincipalRelationshipStatus`, `PrincipalChangedWebhook`, and `GrantAbsentDetails`. Generate the private models offline with `python scripts/generate_identity_preview.py`; validate behavioral constraints with the trust helpers. -## Preview types and rollout +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. + +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. + +`PrincipalService(relationship_resolver=...)` accepts an async seller-owned lookup keyed by the authenticated `PrincipalIdentity`. `relationship` is a 3.3 field: `PrincipalService.get_principal(..., context=tool_context)` consults the resolver and emits it only when `context.resolved_adcp_version` is 3.3 or later. Because `COMPATIBLE_ADCP_VERSIONS` does not include 3.3 yet, it is emitted only once a server serves 3.3; calls that omit `context=` never emit it. 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.check_listing(...)` when applying an `open` policy, before provisioning access. + +AdCP 3.3 deprecates the RFC 9421 profile; it keeps working through 3.x. `SigningConfig` and `RequestSignatureVerification` emit a `DeprecationWarning` for it only once the SDK advertises 3.3 (`COMPATIBLE_ADCP_VERSIONS`), so legacy constructions stay quiet while 3.3 is not served. `async_resolve_agent`, `verify_from_agent_url`, BrandJsonJwksResolver and governance/rights/document JWS verification retain their existing chain until 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. -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. +## Differences from the other SDKs -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. +- **Nonce acceptance.** The verifier accepts 22 to 256 characters (a 128-bit floor). TypeScript keeps a 16-byte floor; Go currently requires exactly 64 bytes and is being changed to accept a range. +- **Signed directories with an unsigned key.** Only keys with a verified signature are usable (draft directory-05). Go accepts every key in a signed directory; TypeScript refuses the directory. +- **Discovery capacity.** A local discovery-limit refusal is a 429, as in TypeScript. Go treats it as a discovery failure. +- **Directory created skew.** Directory signatures tolerate 60 seconds of `created` skew, as in TypeScript; Go allows none. All SDKs backdate `created` when publishing. +- **Spend classification.** Fails closed for `update_media_buy`/`control_media_buy` (only renames, pauses and cancellations are non-spend); Go treats every `update_media_buy` as spend and `control_media_buy` as non-spend. +- **Unsigned-request challenge.** Python adds `Accept-Signature` to the 401 for unsigned required requests (and answers 403 when only `web-bot-auth` is offered), per the spec's Coexistence rules. Go and TypeScript do not yet. +- **Revocation admission.** An over-capacity first discovery that cannot evict anything is `"unavailable"` (every bound call fails), never `"stale"`. +- **Grant-gate claim history.** The gate no longer keeps its own approval/denial history (and its `max_denials` / `max_claims_per_operator` options are gone): the evaluator's last record read is the single source, as in Go. A seller-authorization hook must therefore order its own concurrent decisions. -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. +## Breaking changes to the experimental API -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. +The experimental identity surface changed incompatibly with the previous preview: -`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. +- `WbaUnavailableError` is an application-layer infrastructure error (`RuntimeError`), no longer a `WbaVerificationError` with `status`/`headers`. Discovery failures are `WbaDiscoveryError`; a revoked key is `WbaRevokedKeyError` (`WbaAuthorizationError` remains as an alias). +- `verify_wba_request(revocation_checker=...)` is optional; `OriginRevocationChecker.status()` replaces the boolean call, which now raises for undecidable states. +- `TrustGrantGate(max_denials=..., max_claims_per_operator=...)` are gone; `require_claim(allow_cached_binding_on_outage=)` is `non_spend=`; `require_account(bound_wba_identity=)` is gone and `wba_revocation=` is new. +- `TrustRecordEvaluator.bind` requires `signer.revocation == "current"`. +- `RequestSignatureVerification.spend_classifier` receives `None` when the arguments cannot be read; the binding logic moved from the middleware into `OriginBindingGate`. +- Standalone `SignedRequestVerificationMiddleware` authorization errors are the AdCP envelope with 403/503, no longer 401 `{"error": "unauthenticated"}`. +- `adcp.types.experimental_identity.BuyerOnboardingMode` is removed (its values are gone); the buyer posture uses `AgentOnboardingMode`. +- The delisted-origin task-control exception, and its A2A owner marker, are removed. -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. +`adcp.signing` and `adcp.types.experimental_identity` are now part of the public-API snapshot (`tests/fixtures/public_api_snapshot.json`), so additions and removals there are reviewed. -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. +## Not covered yet -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. +- The final 3.3 schema bundle and the P7 trust-record and grant-chain conformance vectors are upstream release dependencies; re-pin and rerun conformance when they land. +- Konstantin Mirin's follow-ups in #1511 that remain open: building the profile on the `http-message-signatures` library (and moving from `http-sfv` to `http-sf`), which would also retire the WBA signature-base builder and the strict structured-field pre-scan, and accepting the legacy sf-string `Signature-Agent`. +- Durable stores: bindings and replay state are pluggable; authority history and revocation history are in memory with no pluggable store yet (Go and TypeScript have one). Public deployments need durable versions of all four. +- The directory cache is evictable by unauthenticated traffic naming many origins (as in Go); eviction only forces a refetch, but a signer whose directory is down at that moment loses its stale-copy continuation. diff --git a/pyproject.toml b/pyproject.toml index 2aebdd30a..c230e2eca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,15 @@ dependencies = [ # streamable-HTTP session manager's explicit configuration arguments. "mcp>=2.0.0,<3.0", "email-validator>=2.0.0", - "cryptography>=41.0.0", + # Floor at 45.0.1 because joserfc (below) requires it. + "cryptography>=45.0.1", + # JOSE (RFC 7515 JWS, RFC 7517 JWK, RFC 7638 thumbprints) for + # ``adcp.signing.jws`` and ``adcp.signing.keygen``: the SDK keeps only the + # AdCP profile rules (typ, alg allow-list, strict JSON) on top. BSD-3. + "joserfc>=1.7.5,<2", + # Standard Webhooks v1, the specification's own reference package, for + # ``adcp.signing.standard_webhooks``. MIT, no dependencies. + "standardwebhooks>=1.1.0,<2", # RFC 8785 JSON Canonicalization Scheme — used by the server-side # idempotency middleware to compute the spec-mandated payload hash for # replay detection. Tiny pure-Python dep, no transitive weight. diff --git a/scripts/export_resolution.py b/scripts/export_resolution.py index d2c7539cc..9177cd8bb 100644 --- a/scripts/export_resolution.py +++ b/scripts/export_resolution.py @@ -39,6 +39,8 @@ def snapshot_modules() -> tuple[tuple[str, str], ...]: ("adcp", "adcp"), ("adcp.types", "adcp.types"), ("adcp.types._generated", "adcp.types._generated"), + ("adcp.signing", "adcp.signing"), + ("adcp.types.experimental_identity", "adcp.types.experimental_identity"), *( (f"adcp.types.domains.{domain}", f"adcp.types.domains.{domain}") for domain in adcp.types.domains.DOMAINS diff --git a/scripts/generate_identity_preview.py b/scripts/generate_identity_preview.py index f00891975..91d56ad68 100644 --- a/scripts/generate_identity_preview.py +++ b/scripts/generate_identity_preview.py @@ -1,7 +1,9 @@ """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. +Each source schema is copied verbatim from the open spec branch recorded in +_identity_schemas/UPSTREAM.json; trust/v1/trust.json is composed there (spec main +plus the onboarding and grants branch diffs), so this script composes nothing. """ from __future__ import annotations @@ -38,18 +40,6 @@ def load(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): diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 213945a61..1a3fc9175 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -1714,13 +1714,13 @@ async def _resolve_account( 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), + wba_revocation=self._wba_revocation(ctx), ) elif self._brand_authorization_gate is not None: buyer_agent_for_gate = cast( @@ -1741,15 +1741,13 @@ def _wba_spend_decision(cls, ctx: ToolContext) -> bool | 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.""" + def _wba_revocation(cls, ctx: ToolContext) -> str | None: + """The verified signer's revocation state, from trusted transport metadata.""" 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"] - ) + if auth_info is None or auth_info.extra.get("signing_profile") != "web-bot-auth": + return None + value = auth_info.extra.get("wba_revocation") + return value if isinstance(value, str) else None @classmethod def _grant_signing_profile( @@ -3177,14 +3175,20 @@ async def sync_accounts( # type: ignore[override] 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), + if isinstance(row, dict) and row.get("account_id"): + # An existing account's authority comes from seller-owned + # state, never from brand/operator fields beside its id. + await self._resolve_account( + {"account_id": row["account_id"]}, tool_ctx, tool_name="sync_accounts" ) + if "brand" not in row and "operator" not in row: + continue + await self._trust_grant_gate.require_declaration( + row if isinstance(row, Mapping) else {}, + tool_ctx.metadata.get(_BUYER_AGENT_METADATA_KEY), + signing_profile=self._grant_signing_profile(tool_ctx), + wba_revocation=self._wba_revocation(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/trust_grants.py b/src/adcp/decisioning/trust_grants.py index 36a69f7c3..6c5d67400 100644 --- a/src/adcp/decisioning/trust_grants.py +++ b/src/adcp/decisioning/trust_grants.py @@ -3,18 +3,14 @@ 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 dataclasses import dataclass 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 +from adcp.signing.trust import TrustGrantEvaluator @dataclass(frozen=True) @@ -33,19 +29,6 @@ class TrustGrantClaim: [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. @@ -74,12 +57,16 @@ class _DenialHistory: ) -def _cached_binding_may_continue( +def _is_non_spend( operation: str | None, params: Mapping[str, Any] | None, spend_committing: bool | None, ) -> bool: - """Use the transport's spend decision, with conservative direct-call defaults.""" + """Whether ``operation`` commits no spend, so it may continue during an outage. + + Uses the transport's spend decision, with conservative direct-call + defaults: provisioning and unknown operations count as spend. + """ if operation is None or operation == "sync_accounts": return False if spend_committing is not None: @@ -95,32 +82,52 @@ def _cached_binding_may_continue( return not is_wba_spend_committing(operation, params) +def _grant_absent(message: str) -> AdcpError: + return AdcpError( + "PERMISSION_DENIED", + message=message, + details={"scope": "agent", "reason": "grant_absent"}, + recovery="correctable", + ) + + +def _seller_claim_unavailable() -> AdcpError: + # PERMISSION_DENIED without details: grant_absent is reserved for a claim + # that no grant covers. + return AdcpError( + "PERMISSION_DENIED", + message="Account has no resolvable grant claim for this operation", + recovery="correctable", + ) + + +def _unestablished() -> AdcpError: + # No established commercial identity: omit details under the oracle clamp. + return AdcpError( + "PERMISSION_DENIED", message="Buyer agent is not authorized", recovery="correctable" + ) + + @dataclass(frozen=True) class TrustGrantGate: - """Extract existing-account authority from seller-owned state. + """Apply demand-grant evaluation before account side effects. - 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. + Natural-key ``sync_accounts`` declarations are checked directly before + upsert. Existing accounts use ``extract_claim`` (or + ``extract_operation_claim``), reading seller-owned state, so a caller + cannot omit the brand to bypass grant checking; an extractor that fails, + or yields a claim without a brand or operator, fails closed. Requires a + BuyerAgentRegistry in the composition root. + + The gate keeps no claim history of its own. The trust-record evaluator's + last record read is authoritative: a withdrawn grant stays withdrawn + through any later outage, and a non-spend claim continues on the last + records read only within the evaluator's outage-continuation bound. """ 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, @@ -128,89 +135,33 @@ async def require_claim( agent: BuyerAgent | None, *, signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - allow_cached_binding_on_outage: bool = False, + non_spend: bool = False, + wba_revocation: str | None = None, ) -> 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] + """Authorize ``claim`` for ``agent``; raise the AdCP error otherwise. - 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: + ``non_spend`` marks a claim for an operation that commits no spend: + only such a claim continues on the last records read during an + outage. ``wba_revocation`` is the verified signer's revocation state: + a stale list fails spend closed and an unavailable store fails every + claim, with the retryable ``SERVICE_UNAVAILABLE``. + """ if agent is None: - # No established commercial identity: omit details under the oracle clamp. + raise _unestablished() + try: + operator = registrable_domain(agent.agent_url) + except ValueError: + operator = None + if operator is None: + raise _unestablished() + if signing_profile == "web-bot-auth" and ( + wba_revocation == "unavailable" or (wba_revocation == "stale" and not non_spend) + ): raise AdcpError( - "PERMISSION_DENIED", message="Buyer agent is not authorized", recovery="correctable" + "SERVICE_UNAVAILABLE", + message="Origin revocation status is temporarily unavailable", + recovery="transient", ) - 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, @@ -219,69 +170,8 @@ async def _require_admitted_claim( countries=claim.countries, scope=claim.scope, signing_profile=signing_profile, + allow_stale_on_outage=non_spend, ) - 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( @@ -291,40 +181,41 @@ async def require_account( *, 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, + wba_revocation: str | 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 + raise _unestablished() + try: + 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 + except AdcpError: + raise + except Exception as exc: + # Missing seller-owned brand/operator state never authorizes. It is + # a seller-side failure, not "no grant covers the claim". + raise _seller_claim_unavailable() from exc + if not isinstance(claim, TrustGrantClaim): + raise _seller_claim_unavailable() 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", - ) + # The seller did not choose a scope for this operation. + raise _seller_claim_unavailable() 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) - ), + non_spend=_is_non_spend(operation, operation_params, spend_committing), + wba_revocation=wba_revocation, ) async def require_declaration( @@ -333,21 +224,18 @@ async def require_declaration( agent: BuyerAgent | None, *, signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", + wba_revocation: str | None = None, ) -> None: + if agent is None: + raise _unestablished() 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", - ) + raise _grant_absent("Account declaration has no grant authority") + # Provisioning always requires fresh authority. await self.require_claim( TrustGrantClaim( brand["domain"], @@ -357,6 +245,7 @@ async def require_declaration( ), agent, signing_profile=signing_profile, + wba_revocation=wba_revocation, ) diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index 4f2e89209..792365c31 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -92,7 +92,6 @@ async def validate_token(token: str) -> Principal | None: 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.signing.errors import signature_challenge @@ -641,9 +640,7 @@ 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 - ): + if scope_has_wba_error_projection(request.scope): principal_token = current_principal.set(None) tenant_token = current_tenant.set(None) metadata_token = current_principal_metadata.set(None) @@ -1745,11 +1742,11 @@ 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): + if scope_has_wba_error_projection(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. + # Error-only dispatch 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) diff --git a/src/adcp/server/principal.py b/src/adcp/server/principal.py index 840499ca6..4980c80e9 100644 --- a/src/adcp/server/principal.py +++ b/src/adcp/server/principal.py @@ -15,13 +15,14 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol from urllib.parse import urlsplit, urlunsplit from uuid import uuid4 from pydantic import BaseModel, TypeAdapter from adcp._deferred_adapters import deferred_adapter +from adcp._version import normalize_to_release_precision from adcp.server.idempotency import canonical_json_sha256 from adcp.types import ( AgentDeclarations, @@ -38,6 +39,31 @@ SyncPrincipalResponse, ) +if TYPE_CHECKING: + from adcp.server.base import ToolContext + +# get_principal relationship is added in AdCP 3.3 (DR-0024): result arms served +# below 3.3 are closed, so the field is emitted only at 3.3 or later. +_RELATIONSHIP_MIN_RELEASE = (3, 3) + + +def _serves_relationship(context: ToolContext | None) -> bool: + """Whether the response is served at release 3.3 or later. + + Compares release precision (major.minor), so a 3.3 prerelease counts as 3.3. + An absent, unversioned or unparseable version is treated as pre-3.3. + """ + version = getattr(context, "resolved_adcp_version", None) + if not isinstance(version, str): + return False + try: + release = normalize_to_release_precision(version) + except ValueError: + return False + major, minor = release.split("-", 1)[0].split(".")[:2] + return (int(major), int(minor)) >= _RELATIONSHIP_MIN_RELEASE + + # AgentReportingDestination is a union, so validation goes through an adapter. @@ -371,10 +397,23 @@ async def get_principal( self, identity: PrincipalIdentity, request: GetPrincipalRequest | Mapping[str, Any] | None = None, + *, + context: ToolContext | None = None, ) -> GetPrincipalResponse: - """Read state and optional experimental relationship for this authenticated caller.""" + """Read state and optional experimental relationship for this authenticated caller. + + ``relationship`` is a 3.3-only field: the resolver is consulted, and the + field emitted, only when ``context.resolved_adcp_version`` is 3.3 or + later. Without a context, or for an unversioned or older release, the + response keeps the closed pre-3.3 result shape. Pass the handler's + ``ToolContext`` so the served release is known. + """ response = await self._get_principal_state(identity, request) - if self._relationship_resolver is None or response.result.kind == "failed": + if ( + self._relationship_resolver is None + or response.result.kind == "failed" + or not _serves_relationship(context) + ): return response relationship = await self._relationship_resolver(identity) if relationship is None: diff --git a/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index da3d3c9d9..cfb5c11e8 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -68,10 +68,13 @@ verify_request_signature, ) from adcp.signing.wba import ( + GENERIC_AUTHENTICATION_ERROR, OriginRevocationChecker, - WbaAuthorizationError, + WbaRevokedKeyError, WbaUnavailableError, WbaVerificationError, + accept_signature_value, + check_wba_revocation, uses_web_bot_auth, verify_wba_request, ) @@ -79,6 +82,7 @@ if TYPE_CHECKING: from adcp.decisioning.types import AdcpError from adcp.server.base import ToolContext + from adcp.server.wba_authorization import WbaAuthorizer from adcp.signing.signer_keys import ResolvedSignerKey, SignerKeyResolver from adcp.signing.trust import TrustRecordEvaluator from adcp.signing.verifier import CoversDigestPolicy, SigningProfileVersion @@ -97,17 +101,11 @@ _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 +#: Always allowed to an identity-established MCP signer, in addition to +#: ``unbound_for``: without them it cannot open a session to reach the +#: operations ``unbound_for`` allows. They invoke no tool. +_MCP_SESSION_METHODS = frozenset({"initialize", "notifications/initialized", "ping", "tools/list"}) current_wba_spend_committing: contextvars.ContextVar[bool | None] = contextvars.ContextVar( @@ -119,10 +117,20 @@ class _AuthorizationDecision: 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_" - ): + """Classify spend-committing operations for the trust and revocation outage rules. + + Buys, acquisitions and ``activate_signal`` commit spend; so do unknown + operations. ``update_media_buy`` and ``control_media_buy`` commit spend + unless every change is a rename, a pause or a cancellation. Sellers that + can compare a change with their own state (for example, a budget + reduction) can supply their own ``spend_classifier``. + """ + if operation in { + "create_media_buy", + "buy_products", + "accept_proposal", + "activate_signal", + } 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 @@ -147,18 +155,53 @@ def is_wba_spend_committing(operation: str, params: Mapping[str, Any] | None = N ) 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 not _is_known_nonspend_change(params) + + +# update_media_buy / control_media_buy change spend unless every change is one +# of these. Anything else (budgets, bids, impressions, flight dates, spend +# targets, new packages, resumes, unknown fields) counts as spend: fail closed. +_NONSPEND_BUY_FIELDS = frozenset( + { + "media_buy_id", + "account", + "idempotency_key", + "context", + "ext", + "governance_context", + "revision", + "push_notification_config", + "reporting_webhook", + "adcp_major_version", + "adcp_version", + "name", + "cancellation_reason", + } +) +_NONSPEND_PACKAGE_FIELDS = frozenset( + {"package_id", "buyer_ref", "context", "ext", "cancellation_reason"} +) + + +def _is_known_nonspend_change(params: Mapping[str, Any]) -> bool: + """Only renames, pauses and cancellations are known not to commit spend.""" + + def stops(change: Mapping[str, Any], allowed: frozenset[str]) -> bool: + for key, value in change.items(): + if key in {"paused", "canceled"}: + if value is not True: + return False + elif key not in allowed: + return False return True - packages = params.get("packages") - if not isinstance(packages, list): + + if not stops({k: v for k, v in params.items() if k != "packages"}, _NONSPEND_BUY_FIELDS): return False - return any( - isinstance(package, Mapping) - and (package.get("paused") is False or bool(financial.intersection(package))) + packages = params.get("packages") + if packages is None: + return True + return isinstance(packages, list) and all( + isinstance(package, Mapping) and stops(package, _NONSPEND_PACKAGE_FIELDS) for package in packages ) @@ -183,6 +226,25 @@ def check_wba_authorization(context: ToolContext | None) -> None: _SIGNATURE_FALLBACK_SCOPE_KEY = "adcp.server.signature_fallback" +def _accepts_signer_origin(resolver: Any) -> bool: + """Whether ``resolver`` can be called with ``signer_origin=...``.""" + import inspect + + try: + parameters = inspect.signature(resolver).parameters.values() + except (TypeError, ValueError): + return False + return any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + or ( + parameter.name == "signer_origin" + and parameter.kind + in {inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD} + ) + for parameter in parameters + ) + + @dataclass(frozen=True, kw_only=True) class RequestSignatureVerification: """Configuration for framework request-signature verification. @@ -227,16 +289,51 @@ class RequestSignatureVerification: 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 wba_revocation_checker: Origin revocation checker for the + authorization layer, run after verification. When Web Bot Auth is + enabled, defaults to ``OriginRevocationChecker`` using the configured + signer keys. A revoked key is refused with 403 and no + ``Accept-Signature``; an origin with no list stays + identity-established; a stale list fails spend closed with + ``SERVICE_UNAVAILABLE``. Never consulted on the signature path. :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. + :param wba_authorizer: The authorization seam for verified Web Bot Auth + origins: an async ``(signer, operation, params) -> principal | None`` + (:class:`~adcp.server.wba_authorization.WbaAuthorizer`). Defaults to + :class:`~adcp.server.wba_authorization.OriginBindingGate` built from + ``origin_bindings``, ``trust_records``, ``binding_role`` and + ``spend_classifier``. Supply your own to keep binding and trust + evaluation in your application. + :param unbound_for: Operations an identity-established caller may call + with its verified identity attached. Any other operation is served + exactly as an anonymous request (no verified signer), so the caller + gets what an anonymous caller gets; an operation in ``required_for`` + or ``protocol_methods_required_for`` is refused with + ``PERMISSION_DENIED``. ``get_principal`` and, on MCP, the session + methods (``initialize``, ``notifications/initialized``, ``ping``, + ``tools/list``) are always allowed. Add ``sync_principal`` only when + its handler implements an open policy. + :param wba_discovery_failure: Local policy for a Web Bot Auth request + whose signer's key directory cannot be resolved (WBA §6.10) and no + usable cached copy holds the key. ``"reject"`` (default) answers 403 + with ``Accept-Signature``; ``"anonymous"`` serves it exactly as an + unsigned request, except operations in ``required_for`` / + ``protocol_methods_required_for``, which are still refused. + :param wba_replay_status: Status for a reused Web Bot Auth nonce: 429, as + the spec requires; 403 only reproduces #7894's older replay vector. + :param authorities: The ``host[:port]`` values this endpoint serves. A Web + Bot Auth request whose ``@authority`` is not among them is refused + before key discovery, so a signature captured at another seller + cannot be replayed here under that seller's ``Host``. Strongly + recommended for any endpoint reachable under more than one name. + :param rfc9421_profile: Whether this verifier also advertises the RFC 9421 + profile. With Web Bot Auth enabled, an unsigned request to a required + operation is answered as the RFC 9421 profile does (401 + ``request_signature_required``) plus ``Accept-Signature`` for Web Bot + Auth. Set ``False`` when only ``web-bot-auth`` is advertised: such a + request, and any RFC 9421 signature, gets 403 with ``Accept-Signature``. """ signer_keys: SignerKeyResolver @@ -256,17 +353,31 @@ class RequestSignatureVerification: 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 + spend_classifier: Callable[[str, Mapping[str, Any] | None], bool] = is_wba_spend_committing + wba_authorizer: WbaAuthorizer | None = None + _default_wba_authorizer: WbaAuthorizer | None = field( + default=None, init=False, repr=False, compare=False ) + wba_discovery_failure: Literal["reject", "anonymous"] = "reject" + wba_replay_status: Literal[403, 429] = 429 + authorities: frozenset[str] | None = None + rfc9421_profile: bool = True def __post_init__(self) -> None: + if self.wba_discovery_failure not in {"reject", "anonymous"}: + raise ValueError("wba_discovery_failure must be 'reject' or 'anonymous'") + if self.wba_replay_status not in {403, 429}: + raise ValueError("wba_replay_status must be 403 or 429") + if not self.rfc9421_profile and not self.enable_web_bot_auth: + raise ValueError("a verifier needs at least one signing profile") 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 not _accepts_signer_origin(self.signer_keys): + raise TypeError( + "Web Bot Auth requires signer_keys that resolve by origin: " + "its __call__ must accept the keyword argument signer_origin " + "(use DiscoveredSignerKeys)" + ) if self.enable_web_bot_auth and self.wba_revocation_checker is None: object.__setattr__( self, @@ -277,7 +388,24 @@ def __post_init__(self) -> None: from adcp.signing.trust import TrustRecordEvaluator object.__setattr__(self, "trust_records", TrustRecordEvaluator()) - if not self.enable_web_bot_auth: + if self.enable_web_bot_auth and self.wba_authorizer is None: + from adcp.server.wba_authorization import OriginBindingGate + + # Derived, not stored in wba_authorizer, so dataclasses.replace + # with new bindings or trust records rebuilds it. + object.__setattr__( + self, + "_default_wba_authorizer", + OriginBindingGate( + self.origin_bindings, + self.trust_records, + role=self.binding_role, + spend_classifier=self.spend_classifier, + ), + ) + from adcp.signing._profile_deprecation import rfc9421_deprecation_active + + if not self.enable_web_bot_auth and rfc9421_deprecation_active(): 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.", @@ -301,6 +429,11 @@ def __post_init__(self) -> None: "operation (its params.name) in required_for / warn_for instead" ) + @property + def effective_wba_authorizer(self) -> WbaAuthorizer | None: + """The configured :class:`WbaAuthorizer`, or the default binding gate.""" + return self.wba_authorizer or self._default_wba_authorizer + @classmethod def from_capability( cls, @@ -337,6 +470,11 @@ def names(key: str) -> frozenset[str]: digest = capability.get("covers_content_digest") if digest is not None: kwargs["covers_content_digest"] = digest + profiles = capability.get("profiles") + if isinstance(profiles, (list, tuple)) and "web-bot-auth" in profiles: + # The experimental profile list says which profiles are offered. + kwargs["enable_web_bot_auth"] = True + kwargs["rfc9421_profile"] = "adcp/request-signing/v1" in profiles kwargs.update(overrides) return cls(signer_keys=signer_keys, **kwargs) @@ -435,8 +573,56 @@ def _header(scope: Mapping[str, Any], name: bytes) -> str | None: return None +def _received_target_uri(scope: Mapping[str, Any]) -> str: + """The request's target URI as received (RFC 9421 §2.2.2). + + The scheme, the ``Host`` field and the request-target bytes as they + arrived (``raw_path`` plus the query string), so percent-encoding is + preserved. Starlette's ``request.url`` decodes the path and must not be + used for ``@target-uri``. + """ + scheme = str(scope.get("scheme") or "http") + host = _header(scope, b"host") + if host is None: + server = scope.get("server") + if isinstance(server, (tuple, list)) and len(server) == 2: + name = f"[{server[0]}]" if ":" in str(server[0]) else str(server[0]) + host = ( + name + if (scheme, server[1]) in {("https", 443), ("http", 80)} + else (f"{name}:{server[1]}") + ) + else: + host = "" + raw_path = scope.get("raw_path") + if isinstance(raw_path, bytes) and raw_path: + path = raw_path.decode("latin-1") + else: + # Without raw_path the decoded path is the best available; a target + # that was percent-encoded then fails verification (fails closed). + path = str(scope.get("path", "")) + if "?" in path: + # Some servers keep the query in raw_path; never append it twice. + path = path.split("?", 1)[0] + query = scope.get("query_string") or b"" + target = f"{scheme}://{host}{path}" + if query: + target += "?" + (query.decode("latin-1") if isinstance(query, bytes) else str(query)) + return target + + class SignedRequestVerificationMiddleware: - """Pure-ASGI middleware enforcing :class:`RequestSignatureVerification`.""" + """Pure-ASGI middleware enforcing :class:`RequestSignatureVerification`. + + Under Web Bot Auth it runs the two layers in order. The signature path + (:func:`~adcp.signing.verify_wba_request`, key directory and replay store + only) answers failures with Web Bot Auth statuses and headers and the + generic authentication error. The authorization layer (revocation, then + the binding re-evaluated against the operator's trust record) answers + with the standard AdCP error envelope, never 403 with + ``Accept-Signature``. Infrastructure outages are application-layer + ``SERVICE_UNAVAILABLE``. + """ def __init__( self, @@ -457,7 +643,6 @@ def __init__( ) 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), @@ -478,51 +663,19 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: 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 + if not self._config.enable_web_bot_auth: + await self._reject_wba( + scope, + receive, + send, + WbaVerificationError("Web Bot Auth is not enabled", challenge=False), ) - 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, + body = json.dumps(existing.parsed_body).encode() + target = _jsonrpc_target( + body, self._transport, self._message_parser, parse_skill=True ) + await self._serve_verified_wba(scope, receive, send, (existing,), body, target) return principal = ( await self._config.origin_bindings(origin) @@ -582,6 +735,15 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: scope[_SIGNATURE_FALLBACK_SCOPE_KEY] = REQUEST_SIGNATURE_REQUIRED await self._app(scope, replay, send) return + if self._config.enable_web_bot_auth and not self._config.rfc9421_profile: + # Only web-bot-auth is advertised: the WBA challenge. + await self._reject_wba( + scope, + replay, + send, + WbaVerificationError("operation requires a signature"), + ) + return await self._reject( send, SignatureVerificationError( @@ -589,6 +751,7 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: step=0, message=f"operation {label!r} requires a signature", ), + accept_signature=self._config.enable_web_bot_auth, ) return if posture == "warn": @@ -599,17 +762,21 @@ 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 + if wba: + await self._serve_wba(scope, replay, send, body, target, posture) + return + if not self._config.rfc9421_profile: + # The RFC 9421 profile is not offered here: ask for Web Bot Auth. + await self._reject_wba( + scope, + replay, + send, + WbaVerificationError("only the Web Bot Auth profile is accepted"), ) - 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 + + try: + signer, authorization_error = await self._verify(scope, body, label, posture) except SignatureVerificationError as exc: if posture == "warn" and exc.code != REQUEST_SIGNATURE_HEADER_MALFORMED: logger.warning( @@ -626,18 +793,142 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: send, signer, authorization_error=authorization_error, - prior_task_control=decision.prior_task_control, ) + async def _serve_wba( + self, + scope: Any, + receive: Any, + send: Any, + body: bytes, + target: _Target, + posture: SignaturePosture | None, + ) -> None: + """Verify a request selected for Web Bot Auth, then authorize it.""" + config = self._config + if not config.enable_web_bot_auth: + # A profile this endpoint does not accept: no challenge to sign + # with it, and never a fallback to the RFC 9421 profile. + await self._reject_wba( + scope, + receive, + send, + WbaVerificationError("Web Bot Auth is not enabled", challenge=False), + ) + return + assert config.replay_store is not None + request = Request(scope) + try: + signers = await verify_wba_request( + method=request.method, + url=_received_target_uri(scope), + headers=dict(request.headers), + body=body, + signer_keys=cast(WbaSignerKeyResolver, config.signer_keys), + replay_store=config.replay_store, + raw_headers=scope.get("headers"), + replay_status=config.wba_replay_status, + authorities=config.authorities, + ) + except WbaUnavailableError as exc: + logger.warning("web bot auth verifier outage", extra={"reason": str(exc)}) + await self._project_wba_outage(scope, receive, send, body) + return + except WbaVerificationError as exc: + if ( + exc.discovery_failure + and config.wba_discovery_failure == "anonymous" + and posture != "required" + ): + # Unverified (WBA §6.10): local policy serves it as an + # anonymous request, with no verified signer. + logger.info( + "web bot auth key discovery failed; serving as anonymous", + extra={"adcp_operation": target.operation or target.method or ""}, + ) + await self._app(scope, receive, send) + return + await self._reject_wba(scope, receive, send, exc) + return + await self._serve_verified_wba(scope, receive, send, signers, body, target) + + async def _serve_verified_wba( + self, + scope: Any, + receive: Any, + send: Any, + signers: tuple[VerifiedSigner, ...], + body: bytes, + target: _Target, + ) -> None: + """The authorization layer for verified Web Bot Auth signers.""" + from adcp.decisioning.types import AdcpError + + config = self._config + posture = config.posture_for(target.method, target.operation, opaque=target.opaque) + operation = target.operation or target.method or "" + if config.wba_revocation_checker is not None: + try: + signers = await check_wba_revocation(config.wba_revocation_checker, signers) + except WbaRevokedKeyError as exc: + await self._reject_wba(scope, receive, send, exc) + return + # The originating signer owns the AdCP caller; relays are verified + # independently and do not acquire the originator's account access. + signer = signers[0] + principal, authorization_error = await self._authorize_wba_binding(signer, operation) + if authorization_error is not None: + await self._call_with_signer( + scope, + receive, + send, + replace(signer, principal_id=None), + authorization_error=authorization_error, + ) + return + if principal is None and operation not in self._unbound_operations(): + if posture == "required": + # An anonymous caller would be refused too; the identity is + # established, so the refusal is the AdCP envelope. + await self._call_with_signer( + scope, + receive, + send, + replace(signer, principal_id=None), + authorization_error=AdcpError( + "PERMISSION_DENIED", + message="Caller is not authorized for this operation", + recovery="correctable", + ), + ) + return + # Outside what an identity-established caller may do: it gets + # exactly what an anonymous caller gets, with no verified signer. + logger.info( + "web bot auth signer is identity-established; serving as anonymous", + extra={"adcp_operation": operation}, + ) + # Drop a trusted edge's handshake too: nothing downstream may + # attribute this request to the signer. + scope.pop(VERIFIED_SIGNER_SCOPE_KEY, None) + await self._app(scope, receive, send) + return + await self._call_with_signer(scope, receive, send, replace(signer, principal_id=principal)) + + def _unbound_operations(self) -> frozenset[str]: + allowed = self._config.unbound_for | {"get_principal"} + if self._transport == "mcp": + allowed |= _MCP_SESSION_METHODS + return allowed + async def _verify( self, scope: Any, body: bytes, label: str, posture: SignaturePosture | None, - *, - decision: _AuthorizationDecision | None = None, ) -> tuple[VerifiedSigner, AdcpError | None]: + """Verify an RFC 9421 signature (the deprecated profile).""" resolved: dict[str, ResolvedSignerKey] = {} # The verifier looks the key up at its own checklist step 7, after @@ -654,24 +945,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( @@ -711,186 +984,83 @@ def jwks_resolver(keyid: str) -> dict[str, Any] | None: ) 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.""" + async def _authorize_wba_binding( + self, signer: VerifiedSigner, operation: str + ) -> tuple[str | None, AdcpError | None]: + """Ask the configured :class:`WbaAuthorizer` for the signer's principal.""" 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", - ) + authorizer = self._config.effective_wba_authorizer + if authorizer is None: + return None, None 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( + return await authorizer(signer, operation, self._operation_params(signer)), None + except AdcpError as error: + return None, error + except Exception: + logger.exception("Web Bot Auth authorizer failed") + return None, AdcpError( "PERMISSION_DENIED", - message="Signer origin is not authorized by its operator", + message="Signer origin is not authorized", 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 + def _operation_params(self, signer: VerifiedSigner) -> Mapping[str, Any] | None: + """The operation's arguments from the verified body; ``None`` if unreadable.""" + payload = signer.parsed_body + if not isinstance(payload, Mapping): + return {} + if self._transport == "mcp": + envelope = payload.get("params") + arguments = envelope.get("arguments") if isinstance(envelope, Mapping) else None + return arguments if isinstance(arguments, Mapping) else {} + from adcp.server.a2a_server import parse_a2a_jsonrpc_skill - 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 + try: + _skill, arguments = parse_a2a_jsonrpc_skill(dict(payload), self._message_parser) + except Exception: + # Unreadable arguments classify as spend (fail closed). + return None + return arguments if isinstance(arguments, Mapping) else {} 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 + params = self._operation_params(signer) + return self._config.spend_classifier(operation, params if params is not None else None) - _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"} - ) + @staticmethod + async def _reject_wba(scope: Any, receive: Any, send: Any, exc: WbaVerificationError) -> None: + """A signature-path failure: the WBA status and headers, a generic body.""" + logger.info( + "web bot auth request rejected", + extra={"status": exc.status, "reason": exc.reason, "label": exc.label}, + ) + response = JSONResponse( + GENERIC_AUTHENTICATION_ERROR, status_code=exc.status, headers=exc.headers ) - 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.""" + async def _project_wba_outage(self, scope: Any, receive: Any, send: Any, body: bytes) -> None: + """Answer a verifier infrastructure outage at the application layer. + + No identity is established and no handler runs. A known AdCP skill + receives the retryable ``SERVICE_UNAVAILABLE`` through the transport's + error binding; anything else receives the AdCP error envelope (503). + """ 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 + error = _service_unavailable("Signer identity is temporarily unavailable") 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) + if not self._defer_authorization_errors or not any( + tool["name"] == target.operation for tool in ADCP_TOOL_DEFINITIONS + ): + await _authorization_error_response(error)(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", {}) @@ -935,15 +1105,9 @@ async def _call_with_signer( signer: VerifiedSigner, *, authorization_error: AdcpError | None = None, - prior_task_control: bool = False, ) -> 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) + await _authorization_error_response(authorization_error)(scope, receive, send) return target = _jsonrpc_target( json.dumps(signer.parsed_body).encode(), @@ -961,20 +1125,7 @@ async def _call_with_signer( ) 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 - ) + await _authorization_error_response(authorization_error)(scope, receive, send) return scope[VERIFIED_SIGNER_SCOPE_KEY] = signer scope[_FRAMEWORK_VERIFIED_SCOPE_KEY] = True @@ -986,28 +1137,11 @@ async def _call_with_signer( 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 (signer.profile != "web-bot-auth" or signer.principal_id is not None) + 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 @@ -1027,15 +1161,22 @@ async def _call_with_signer( current_wba_spend_committing.reset(spend_token) @staticmethod - async def _reject(send: Any, exc: SignatureVerificationError) -> None: + async def _reject( + send: Any, exc: SignatureVerificationError, *, accept_signature: bool = False + ) -> None: logger.info( "request signature rejected", extra={"reason": exc.code, "step": exc.step}, ) + headers = dict(unauthorized_response_headers(exc)) + if accept_signature: + # A verifier advertising both profiles also tells a Web Bot Auth + # client how to sign (security.mdx, Coexistence). + headers["Accept-Signature"] = accept_signature_value() response = JSONResponse( {"error": exc.code}, status_code=401, - headers=unauthorized_response_headers(exc), + headers=headers, ) async def no_receive() -> dict[str, Any]: @@ -1044,6 +1185,22 @@ async def no_receive() -> dict[str, Any]: await response({"type": "http"}, no_receive, send) +def _service_unavailable(message: str) -> AdcpError: + from adcp.decisioning.types import AdcpError + + return AdcpError("SERVICE_UNAVAILABLE", message=message, recovery="transient") + + +def _authorization_error_response(error: AdcpError) -> JSONResponse: + """An authorization outcome in the AdCP error envelope, never a WBA challenge.""" + unavailable = error.code == "SERVICE_UNAVAILABLE" + return JSONResponse( + {"errors": [error.to_wire()]}, + status_code=503 if unavailable else 403, + headers={"Retry-After": "30"} if unavailable else None, + ) + + def verified_signer_for_request(request_context: Any) -> VerifiedSigner | None: """Return the framework-verified signer for a dispatching request, if any. @@ -1102,19 +1259,6 @@ def scope_has_wba_error_projection(scope: Mapping[str, Any]) -> bool: ) -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, @@ -1189,6 +1333,7 @@ def apply_verified_signer( "signing_profile": signer.profile, "principal_id": signer.principal_id, "wba_spend_committing": spend_committing, + "wba_revocation": signer.revocation, } if signer.profile == "web-bot-auth" else None @@ -1218,6 +1363,13 @@ def check_request_signature_verification( "RequestSignatureVerification(allow_bearer_fallback=True) needs a fallback " "authenticator; pass serve(auth=BearerTokenAuth(...))" ) + if config.enable_web_bot_auth and config.authorities is None: + logger.warning( + "Web Bot Auth verification has no authorities configured; set " + "RequestSignatureVerification(authorities=...) to the host names this " + "endpoint serves so a signature captured at another seller cannot be " + "replayed here" + ) store = config.replay_store if store is None: logger.warning( diff --git a/src/adcp/server/wba_authorization.py b/src/adcp/server/wba_authorization.py new file mode 100644 index 000000000..5cadba6b4 --- /dev/null +++ b/src/adcp/server/wba_authorization.py @@ -0,0 +1,151 @@ +"""The authorization seam for verified Web Bot Auth origins (experimental, AdCP 3.3). + +The transport middleware verifies signatures, checks revocation, renders the +signature-path statuses, and calls one :class:`WbaAuthorizer` to turn a +verified origin into a principal. Bindings, trust-record evaluation and spend +classification live behind that seam; :class:`OriginBindingGate` is the +shipped implementation, and sellers can inject their own. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Mapping +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from adcp.decisioning.types import AdcpError + from adcp.signing.bindings import OriginBindingResolver + from adcp.signing.trust import TrustRecordEvaluator + from adcp.signing.verifier import VerifiedSigner + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class WbaAuthorizer(Protocol): + """Decide authority for a verified Web Bot Auth origin. + + Return the principal bound to ``signer.agent_url``, or ``None`` for an + identity-established origin (unbound, delisted, or with no revocation + list): the middleware then serves it what an anonymous caller gets. + Raise :class:`~adcp.decisioning.types.AdcpError` for an authorization + outcome (``PERMISSION_DENIED``, or ``SERVICE_UNAVAILABLE`` for a + retryable outage); the middleware renders it in the AdCP error envelope, + never as a signature challenge. Any other exception is answered as + ``PERMISSION_DENIED``. + + ``params`` are the operation's arguments from the verified body, for + spend classification, or ``None`` when they cannot be read (classify as + spend). ``signer.revocation`` holds the revocation state + the middleware recorded. + """ + + async def __call__( + self, signer: VerifiedSigner, operation: str, params: Mapping[str, Any] | None + ) -> str | None: + raise NotImplementedError("return the bound principal or None") + + +def _service_unavailable(message: str) -> AdcpError: + from adcp.decisioning.types import AdcpError + + return AdcpError("SERVICE_UNAVAILABLE", message=message, recovery="transient") + + +class OriginBindingGate: + """The shipped :class:`WbaAuthorizer`: bindings re-evaluated against trust records. + + An unbound origin is identity-established. A bound origin needs a + revocation list and a current listing: + + * revocation ``"current"`` proceeds; ``"stale"`` fails spend with + ``SERVICE_UNAVAILABLE`` and lets other calls proceed; ``"unavailable"`` + fails every call; no list or no check leaves the origin + identity-established; + * the operator's trust record, re-evaluated on every request through the + evaluator's cache. Listed: the principal. Delisted (the last record read + does not list it, or the operator never published one): + identity-established, with no exception; the binding is kept, so the + origin still cannot downgrade to RFC 9421. Unreadable (a 404 after a + read included): spend fails with ``SERVICE_UNAVAILABLE``; other calls + continue under the binding within the evaluator's outage-continuation + bound, then also fail. + + ``spend_classifier(operation, params)`` defaults to + :func:`adcp.server.signed_requests.is_wba_spend_committing`, a + recommendation sellers can override with their own state. + """ + + def __init__( + self, + bindings: OriginBindingResolver | None, + records: TrustRecordEvaluator | None, + *, + role: str = "adcp:buying", + spend_classifier: Callable[[str, Mapping[str, Any] | None], bool] | None = None, + ) -> None: + if spend_classifier is None: + from adcp.server.signed_requests import is_wba_spend_committing + + spend_classifier = is_wba_spend_committing + self.bindings = bindings + self.records = records + self.role = role + self.spend_classifier = spend_classifier + + async def __call__( + self, signer: VerifiedSigner, operation: str, params: Mapping[str, Any] | None + ) -> str | None: + from adcp.signing.canonical import canonical_origin + from adcp.signing.trust import TrustRecordError, TrustRecordMatchError + + if self.bindings is None or signer.agent_url is None: + return None + origin = canonical_origin(signer.agent_url) + try: + principal = await self.bindings(origin) + except Exception as exc: + logger.exception("origin binding lookup failed") + raise _service_unavailable("Origin binding lookup is temporarily unavailable") from exc + if principal is None: + return None + spend = self.spend_classifier(operation, params) + if signer.revocation == "stale": + if spend: + raise _service_unavailable("Origin revocation status is temporarily unavailable") + elif signer.revocation == "unavailable": + raise _service_unavailable("Origin revocation status is temporarily unavailable") + elif signer.revocation != "current": + # No revocation list, or no check: never acts for its principal. + return None + if self.records is None: + raise _service_unavailable("Origin binding evaluation is unavailable") + listing = await self.records.check_listing(origin, role=self.role) + if listing.record is not None: + # A newer read of the owner's record (by any origin or claim), or + # the record's deadline passing, supersedes this evaluation. + try: + await self.records.validate_snapshots( + (listing.record,), origin_or_domain=origin, role=self.role + ) + except TrustRecordMatchError: + return None + except TrustRecordError as exc: + raise _service_unavailable( + "Operator trust record is temporarily unavailable" + ) from exc + if listing.status == "listed": + return principal + if listing.status == "unlisted": + logger.info( + "bound origin is not listed by its operator", + extra={"reason": type(listing.error).__name__}, + ) + return None + if spend or not listing.within_grace: + raise _service_unavailable("Operator trust record is temporarily unavailable") + return principal + + +__all__ = ["OriginBindingGate", "WbaAuthorizer"] diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index 554df0f1d..f675a9ef6 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -336,6 +336,7 @@ GrantAuthorization, GrantRequest, TrustGrantEvaluator, + TrustListing, TrustRecordAbsentError, TrustRecordError, TrustRecordEvaluator, @@ -360,8 +361,11 @@ DiscoveredSignerKeys, OriginRevocationChecker, WbaAuthorizationError, + WbaDiscoveryError, + WbaRevokedKeyError, WbaUnavailableError, WbaVerificationError, + check_wba_revocation, public_directory_key, sign_key_directory, sign_wba_request, @@ -394,11 +398,19 @@ def __init__(self, *args: object, **kwargs: object) -> None: "`poetry add 'adcp[pg]'`)." ) + # Resolve like the real class, so the public-API snapshot does not depend + # on whether the optional extra is installed. + PgReplayStore.__module__ = "adcp.signing.pg.replay_store" + __all__ = [ "GrantRequest", "WbaAuthorizationError", + "WbaDiscoveryError", + "WbaRevokedKeyError", "WbaUnavailableError", + "check_wba_revocation", + "TrustListing", "RevocationDetails", "OriginRevocationChecker", "EvaluatedTrustRecord", diff --git a/src/adcp/signing/_identity_schemas/UPSTREAM.json b/src/adcp/signing/_identity_schemas/UPSTREAM.json index d781f6554..224bca158 100644 --- a/src/adcp/signing/_identity_schemas/UPSTREAM.json +++ b/src/adcp/signing/_identity_schemas/UPSTREAM.json @@ -3,43 +3,65 @@ "sources": { "core/agent-onboarding.json": { "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" }, "core/agent-onboarding-tier.json": { "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" }, "core/agent-onboarding-requirement.json": { "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" }, "core/buyer-onboarding-posture.json": { "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" }, "enums/agent-onboarding-mode.json": { "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" }, "enums/agent-onboarding-requirement-kind.json": { "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" - }, - "enums/buyer-onboarding-mode.json": { - "pr": 8170, - "commit": "8d3591e70e9a717c7fdfb513360a796fba24ca9a" + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" }, "core/principal-relationship.json": { "pr": 8168, - "commit": "45540941dfd61e80ab8a463f780cb147c82a32f0" + "branch": "bokelley/agent-identity-p4-principal", + "commit": "478f94edbd27b942235e0601652b1ae8cd3fbdd4" }, - "trust/v1/trust.json": { - "pr": 8169, - "commit": "3ce6a14d7afaa71fa78ddf2223ed0421e01205ae" + "enums/principal-relationship-status.json": { + "pr": 8168, + "branch": "bokelley/agent-identity-p4-principal", + "commit": "478f94edbd27b942235e0601652b1ae8cd3fbdd4" }, "core/identity-relying-party.json": { - "pr": 8169, - "commit": "3ce6a14d7afaa71fa78ddf2223ed0421e01205ae" + "pr": null, + "branch": "main", + "commit": "d5f56dbea17ed190fe9b499764be1b697dd33418" + }, + "trust/v1/trust.json": { + "pr": null, + "branch": "main", + "commit": "d5f56dbea17ed190fe9b499764be1b697dd33418", + "composed_from": [ + { + "pr": 8170, + "branch": "bokelley/agent-onboarding-posture", + "commit": "d73bffc950df81ffd0020efc187abc71f4fcd91e" + }, + { + "pr": 8169, + "branch": "bokelley/agent-identity-p5-grants", + "commit": "41615bda07eba8ade1e937134dc71d5d37683487" + } + ] } }, "baseline_dependencies": { @@ -48,8 +70,11 @@ }, "composition": { "trust/v1/trust.json": [ - "agent.profiles.adcp.onboarding from PR #8170", - "record_profiles.adcp.onboarding from PR #8170" + "Base: origin/main trust/v1/trust.json at d5f56dbea17ed190fe9b499764be1b697dd33418 (unchanged on main since #8121, the merge base of both branches below).", + "Applied the PR #8170 diff (git diff origin/main...bokelley/agent-onboarding-posture): native profiles.adcp.onboarding on the record_profiles definition and on agents[].profiles.", + "Applied the PR #8169 diff (git diff origin/main...bokelley/agent-identity-p5-grants, which contains PR #8168): top-level description and the grant, brands, valid_until and exclusive_grants descriptions.", + "PR #8162 (bokelley/agent-identity-p3-security at f009439908f40dac971ffecfd3ff36ae32ba620e) changes only the top-level description, identically to PR #8168; the newer PR #8169 description supersedes it.", + "The two diffs touch disjoint regions apart from the top-level description; the file is otherwise byte-for-byte the patched upstream text." ] } } diff --git a/src/adcp/signing/_identity_schemas/bundle.json b/src/adcp/signing/_identity_schemas/bundle.json index bbf2961d0..ea98aba02 100644 --- a/src/adcp/signing/_identity_schemas/bundle.json +++ b/src/adcp/signing/_identity_schemas/bundle.json @@ -6,7 +6,8 @@ "$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.", + "x-added-in": "3.3.0", + "description": "How a party takes on counterparties it has not worked with. The direction comes from where the value appears: in a seller's account.agent_onboarding, how the seller onboards buyer agents for one billing party or for sandbox access; in a buyer's trust.json posture (core/buyer-onboarding-posture.json), how the buyer considers sellers. Absent means undeclared, never invite_only.", "type": "string", "enum": [ "open", @@ -14,20 +15,20 @@ "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." + "open": "Seller posture: 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, docs/building/by-layer/L1/security.mdx \u00a7Web Bot Auth identities), may proceed without prior contact. A seller that only maps opaque, seller-issued credentials MUST NOT declare open. What open allows is in docs/protocol/get_adcp_capabilities.mdx#accountagent_onboarding. Buyer posture: a seller that meets every listed requirement is in the buyer's consideration set without prior contact; the buyer discovers and calls it, and nothing is sent to the buyer.", + "application": "The counterparty completes an out-of-band step at url before it is recognized for this mode: the buyer agent, in a seller's posture; the seller, in a buyer's posture.", + "invite_only": "The publishing party does not take unsolicited counterparties 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", + "x-added-in": "3.3.0", "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", @@ -38,12 +39,11 @@ "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.", + "grant": "Seller gating a buyer. A demand-side grant chain authorizes the buyer agent to act for the account's brand, evaluated as docs/building/by-layer/L2/accounts-and-agents.mdx \u00a7Grants in trust.json describes (DR-0025); implicit when account.operator, the brand, and the signer's operator are the same registrable domain.", "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.", + "aao_verified": "Buyer gating a seller. The seller holds an AgenticAdvertising.org Verified badge; role is required, and verification_modes, verified_specialisms, and adcp_version narrow it. The field names are the badge token's own claim names (docs/building/verification/aao-verified.mdx).", "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." @@ -60,7 +60,8 @@ "$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.", + "x-added-in": "3.3.0", + "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 aao_verified fields (role, verification_modes, verified_specialisms, adcp_version) use the AgenticAdvertising.org Verified badge token's own claim names (docs/building/verification/aao-verified.mdx). 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": { @@ -78,13 +79,13 @@ "minItems": 1, "description": "aao_verified: accepted verification modes, as the badge token names them." }, - "specialisms": { + "verified_specialisms": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "aao_verified: specialisms the badge must include." + "description": "aao_verified: specialisms the badge's verified_specialisms claim must include." }, "adcp_version": { "type": "string", @@ -107,6 +108,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Agent Onboarding Tier", "x-status": "experimental", + "x-added-in": "3.3.0", "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": { @@ -154,6 +156,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Agent Onboarding Posture", "x-status": "experimental", + "x-added-in": "3.3.0", "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": { @@ -176,11 +179,6 @@ "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." @@ -191,32 +189,16 @@ }, "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).", + "x-added-in": "3.3.0", + "description": "How a buying agent considers sellers: a mode, the requirements a seller must meet, and where a seller's human starts. Published only in the buyer's trust.json: in the agent entry (agents[].profiles.adcp.onboarding), or as a record-level default (profiles.adcp.onboarding). Public and identical for every reader. mode uses the same enum as the seller's posture; here it says how the buyer considers sellers. See specs/agent-identity-3-3.md R7 (#8114).", "type": "object", "properties": { "mode": { - "$ref": "#/definitions/enums_buyer_onboarding_mode" + "$ref": "#/definitions/enums_agent_onboarding_mode" }, "requirements": { "type": "array", @@ -230,11 +212,6 @@ "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." @@ -248,27 +225,36 @@ ], "additionalProperties": true }, + "enums_principal_relationship_status": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Principal Relationship Status", + "description": "State of the seller's commercial relationship with the authenticated caller, as get_principal reports it in relationship.status.", + "type": "string", + "enum": [ + "pending_approval", + "active", + "suspended", + "blocked" + ], + "enumDescriptions": { + "pending_approval": "The seller is reviewing an application or approval for this caller. The same value and meaning as enums/account-status.json pending_approval, which the same human sees on sync_accounts. 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." + }, + "x-status": "experimental", + "x-added-in": "3.3.0" + }, "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.", + "x-added-in": "3.3.0", + "description": "The seller's commercial relationship with the authenticated caller, returned by get_principal only to that caller. Sent only in responses served at 3.3 or later: a seller MUST omit it for callers below 3.3, whose get_principal result arms are closed (DR-0024, #8117). 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." - }, + "$ref": "#/definitions/enums_principal_relationship_status", "description": "Current state of the relationship." }, "expires_at": { @@ -278,7 +264,7 @@ }, "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.", + "description": "The caller's next step while status is pending_approval, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide.", "properties": { "url": { "type": "string", @@ -1061,7 +1047,7 @@ "$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.", + "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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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", @@ -1247,7 +1233,9 @@ "minItems": 1 }, "onboarding": { - "$ref": "#/definitions/core_buyer_onboarding_posture" + "$ref": "#/definitions/core_buyer_onboarding_posture", + "x-added-in": "3.3.0", + "description": "For a buying agent (role adcp:buying): how it considers sellers. Overrides the record-level profiles.adcp.onboarding for this agent. trust.json is the only home for the buyer posture." } }, "additionalProperties": false @@ -1282,7 +1270,7 @@ }, "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.", + "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. In the AdCP profile this is the demand-side grant (#6033, DR-0025), evaluated as L2 \u00a7Grants in trust.json describes (docs/building/by-layer/L2/accounts-and-agents.mdx), at sync_accounts and on the freshness schedule.", "properties": { "grantee": { "$ref": "#/definitions/trust_v1_trust/definitions/domain", @@ -1335,7 +1323,7 @@ "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.", + "description": "Brand IDs from this organization's brand.json that the grant covers. ['*'] or omitted means every brand the grantor owns. On a grantee's own grant to an intermediary, the IDs are those of the brands in the grant it received.", "items": { "type": "string", "pattern": "^([a-z0-9_]+|\\*)$" @@ -1434,7 +1422,9 @@ } }, "onboarding": { - "$ref": "#/definitions/core_buyer_onboarding_posture" + "$ref": "#/definitions/core_buyer_onboarding_posture", + "x-added-in": "3.3.0", + "description": "Default buyer onboarding posture for every buying agent this record lists; an agent entry's profiles.adcp.onboarding overrides it." } }, "additionalProperties": false diff --git a/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json b/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json index 885f1fb62..9420a770e 100644 --- a/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json +++ b/src/adcp/signing/_identity_schemas/core/agent-onboarding-requirement.json @@ -3,7 +3,8 @@ "$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.", + "x-added-in": "3.3.0", + "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 aao_verified fields (role, verification_modes, verified_specialisms, adcp_version) use the AgenticAdvertising.org Verified badge token's own claim names (docs/building/verification/aao-verified.mdx). 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": { @@ -19,11 +20,11 @@ "minItems": 1, "description": "aao_verified: accepted verification modes, as the badge token names them." }, - "specialisms": { + "verified_specialisms": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "aao_verified: specialisms the badge must include." + "description": "aao_verified: specialisms the badge's verified_specialisms claim must include." }, "adcp_version": { "type": "string", diff --git a/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json b/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json index 2ab410cea..e6622e45c 100644 --- a/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json +++ b/src/adcp/signing/_identity_schemas/core/agent-onboarding-tier.json @@ -3,6 +3,7 @@ "$id": "/schemas/core/agent-onboarding-tier.json", "title": "Agent Onboarding Tier", "x-status": "experimental", + "x-added-in": "3.3.0", "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": { diff --git a/src/adcp/signing/_identity_schemas/core/agent-onboarding.json b/src/adcp/signing/_identity_schemas/core/agent-onboarding.json index 309f840e2..0da1961e5 100644 --- a/src/adcp/signing/_identity_schemas/core/agent-onboarding.json +++ b/src/adcp/signing/_identity_schemas/core/agent-onboarding.json @@ -3,6 +3,7 @@ "$id": "/schemas/core/agent-onboarding.json", "title": "Agent Onboarding Posture", "x-status": "experimental", + "x-added-in": "3.3.0", "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": { @@ -21,11 +22,6 @@ "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." diff --git a/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json b/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json index 09943ffac..def039541 100644 --- a/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json +++ b/src/adcp/signing/_identity_schemas/core/buyer-onboarding-posture.json @@ -3,11 +3,12 @@ "$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).", + "x-added-in": "3.3.0", + "description": "How a buying agent considers sellers: a mode, the requirements a seller must meet, and where a seller's human starts. Published only in the buyer's trust.json: in the agent entry (agents[].profiles.adcp.onboarding), or as a record-level default (profiles.adcp.onboarding). Public and identical for every reader. mode uses the same enum as the seller's posture; here it says how the buyer considers sellers. See specs/agent-identity-3-3.md R7 (#8114).", "type": "object", "properties": { "mode": { - "$ref": "/schemas/enums/buyer-onboarding-mode.json" + "$ref": "/schemas/enums/agent-onboarding-mode.json" }, "requirements": { "type": "array", @@ -19,11 +20,6 @@ "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." diff --git a/src/adcp/signing/_identity_schemas/core/principal-relationship.json b/src/adcp/signing/_identity_schemas/core/principal-relationship.json index bdace571c..c3e8c66ed 100644 --- a/src/adcp/signing/_identity_schemas/core/principal-relationship.json +++ b/src/adcp/signing/_identity_schemas/core/principal-relationship.json @@ -3,18 +3,12 @@ "$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.", + "x-added-in": "3.3.0", + "description": "The seller's commercial relationship with the authenticated caller, returned by get_principal only to that caller. Sent only in responses served at 3.3 or later: a seller MUST omit it for callers below 3.3, whose get_principal result arms are closed (DR-0024, #8117). 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." - }, + "$ref": "/schemas/enums/principal-relationship-status.json", "description": "Current state of the relationship." }, "expires_at": { @@ -24,7 +18,7 @@ }, "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.", + "description": "The caller's next step while status is pending_approval, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide.", "properties": { "url": { "type": "string", diff --git a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json b/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json index 5bccc6d15..021bfae00 100644 --- a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json +++ b/src/adcp/signing/_identity_schemas/enums/agent-onboarding-mode.json @@ -3,12 +3,13 @@ "$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.", + "x-added-in": "3.3.0", + "description": "How a party takes on counterparties it has not worked with. The direction comes from where the value appears: in a seller's account.agent_onboarding, how the seller onboards buyer agents for one billing party or for sandbox access; in a buyer's trust.json posture (core/buyer-onboarding-posture.json), how the buyer considers sellers. 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." + "open": "Seller posture: 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, docs/building/by-layer/L1/security.mdx §Web Bot Auth identities), may proceed without prior contact. A seller that only maps opaque, seller-issued credentials MUST NOT declare open. What open allows is in docs/protocol/get_adcp_capabilities.mdx#accountagent_onboarding. Buyer posture: a seller that meets every listed requirement is in the buyer's consideration set without prior contact; the buyer discovers and calls it, and nothing is sent to the buyer.", + "application": "The counterparty completes an out-of-band step at url before it is recognized for this mode: the buyer agent, in a seller's posture; the seller, in a buyer's posture.", + "invite_only": "The publishing party does not take unsolicited counterparties 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 index eb6d07c43..3892029ca 100644 --- a/src/adcp/signing/_identity_schemas/enums/agent-onboarding-requirement-kind.json +++ b/src/adcp/signing/_identity_schemas/enums/agent-onboarding-requirement-kind.json @@ -3,11 +3,11 @@ "$id": "/schemas/enums/agent-onboarding-requirement-kind.json", "title": "Agent Onboarding Requirement Kind", "x-status": "experimental", + "x-added-in": "3.3.0", "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", @@ -18,12 +18,11 @@ "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.", + "grant": "Seller gating a buyer. A demand-side grant chain authorizes the buyer agent to act for the account's brand, evaluated as docs/building/by-layer/L2/accounts-and-agents.mdx §Grants in trust.json describes (DR-0025); implicit when account.operator, the brand, and the signer's operator are the same registrable domain.", "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.", + "aao_verified": "Buyer gating a seller. The seller holds an AgenticAdvertising.org Verified badge; role is required, and verification_modes, verified_specialisms, and adcp_version narrow it. The field names are the badge token's own claim names (docs/building/verification/aao-verified.mdx).", "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/enums/principal-relationship-status.json b/src/adcp/signing/_identity_schemas/enums/principal-relationship-status.json new file mode 100644 index 000000000..56d52fa68 --- /dev/null +++ b/src/adcp/signing/_identity_schemas/enums/principal-relationship-status.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/principal-relationship-status.json", + "title": "Principal Relationship Status", + "description": "State of the seller's commercial relationship with the authenticated caller, as get_principal reports it in relationship.status.", + "type": "string", + "enum": ["pending_approval", "active", "suspended", "blocked"], + "enumDescriptions": { + "pending_approval": "The seller is reviewing an application or approval for this caller. The same value and meaning as enums/account-status.json pending_approval, which the same human sees on sync_accounts. 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." + }, + "x-status": "experimental", + "x-added-in": "3.3.0" +} diff --git a/src/adcp/signing/_identity_schemas/trust/v1/trust.json b/src/adcp/signing/_identity_schemas/trust/v1/trust.json index 7c38395b4..6bdc2e0aa 100644 --- a/src/adcp/signing/_identity_schemas/trust/v1/trust.json +++ b/src/adcp/signing/_identity_schemas/trust/v1/trust.json @@ -3,7 +3,7 @@ "$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.", + "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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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", @@ -187,6 +187,11 @@ "$ref": "/schemas/enums/right-type.json" }, "minItems": 1 + }, + "onboarding": { + "$ref": "/schemas/core/buyer-onboarding-posture.json", + "x-added-in": "3.3.0", + "description": "For a buying agent (role adcp:buying): how it considers sellers. Overrides the record-level profiles.adcp.onboarding for this agent. trust.json is the only home for the buyer posture." } }, "additionalProperties": false @@ -221,7 +226,7 @@ }, "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.", + "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. In the AdCP profile this is the demand-side grant (#6033, DR-0025), evaluated as L2 §Grants in trust.json describes (docs/building/by-layer/L2/accounts-and-agents.mdx), at sync_accounts and on the freshness schedule.", "properties": { "grantee": { "$ref": "#/definitions/domain", @@ -274,7 +279,7 @@ "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.", + "description": "Brand IDs from this organization's brand.json that the grant covers. ['*'] or omitted means every brand the grantor owns. On a grantee's own grant to an intermediary, the IDs are those of the brands in the grant it received.", "items": { "type": "string", "pattern": "^([a-z0-9_]+|\\*)$" @@ -371,6 +376,11 @@ "items": { "$ref": "/schemas/core/identity-relying-party.json" } + }, + "onboarding": { + "$ref": "/schemas/core/buyer-onboarding-posture.json", + "x-added-in": "3.3.0", + "description": "Default buyer onboarding posture for every buying agent this record lists; an agent entry's profiles.adcp.onboarding overrides it." } }, "additionalProperties": false diff --git a/src/adcp/signing/_profile_deprecation.py b/src/adcp/signing/_profile_deprecation.py new file mode 100644 index 000000000..d590d2c26 --- /dev/null +++ b/src/adcp/signing/_profile_deprecation.py @@ -0,0 +1,21 @@ +"""When to announce the RFC 9421 request-signing profile's 3.3 deprecation.""" + +from __future__ import annotations + + +def rfc9421_deprecation_active() -> bool: + """Whether this SDK advertises AdCP 3.3 or later. + + AdCP 3.3 deprecates the RFC 9421 profile (it stays supported through 3.x). + While the SDK's wire pin is below 3.3, warning on every legacy + construction would only add noise for sellers who cannot yet migrate, + so the warning is emitted once a 3.3 release is a compatible version. + """ + from adcp._version import COMPATIBLE_ADCP_VERSIONS + + for version in COMPATIBLE_ADCP_VERSIONS: + major, _, rest = version.partition(".") + minor = rest.split(".", 1)[0].split("-", 1)[0] + if major.isdigit() and minor.isdigit() and (int(major), int(minor)) >= (3, 3): + return True + return False diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index f90140a58..a36bca9aa 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -79,6 +79,12 @@ def signing_profile_for_adcp_version(adcp_version: str) -> SigningProfileVersion return profile # type: ignore[return-value] +def _rfc9421_deprecation_active() -> bool: + from adcp.signing._profile_deprecation import rfc9421_deprecation_active + + return rfc9421_deprecation_active() + + @dataclass(frozen=True) class SigningConfig: """Client-side signing credentials for RFC 9421 request signing. @@ -132,7 +138,7 @@ def __post_init__(self) -> None: 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: + elif _rfc9421_deprecation_active(): 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.", diff --git a/src/adcp/signing/etld.py b/src/adcp/signing/etld.py index 93335346a..34bde3006 100644 --- a/src/adcp/signing/etld.py +++ b/src/adcp/signing/etld.py @@ -264,6 +264,36 @@ def registrable_domain(host_or_url: str) -> str | None: return f"{result.domain}.{result.suffix}".lower() +@lru_cache(maxsize=1) +def _icann_extractor() -> tldextract.TLDExtract: + """ICANN-section-only extractor, used to group hosted tenants for quotas.""" + return tldextract.TLDExtract( + suffix_list_urls=(), + fallback_to_snapshot=True, + include_psl_private_domains=False, + ) + + +def quota_key(host_or_url: str) -> str | None: + """Return the key that per-operator quotas count ``host_or_url`` under. + + This is the registrable domain, except that every tenant of a PSL + PRIVATE-section suffix (a hosting platform such as ``vercel.app`` or + ``github.io``, where anyone can get a free registrable domain) counts + under the platform's ICANN registrable domain. One party with many free + subdomains therefore shares one quota, while + :func:`registrable_domain` still treats each tenant as its own operator + for authority. Returns ``None`` when the host has no registrable domain. + """ + domain = registrable_domain(host_or_url) + if domain is None: + return None + result = _icann_extractor()(domain) + if result.domain and result.suffix: + return f"{result.domain}.{result.suffix}".lower() + return domain + + def same_registrable_domain(a: str, b: str) -> bool: """Return True iff ``a`` and ``b`` share an eTLD+1. @@ -282,6 +312,7 @@ def same_registrable_domain(a: str, b: str) -> bool: "BrandDomainValidationError", "host_from", "is_development_brand_domain", + "quota_key", "registrable_domain", "same_development_brand_domain", "same_registrable_domain", diff --git a/src/adcp/signing/jws.py b/src/adcp/signing/jws.py index 8c4501111..649ce00ee 100644 --- a/src/adcp/signing/jws.py +++ b/src/adcp/signing/jws.py @@ -1,49 +1,61 @@ -"""Minimal JWS parse + verify for AdCP revocation lists. +"""JWS verification for AdCP revocation lists, on top of ``joserfc``. The AdCP governance profile uses JSON Web Signature (RFC 7515) to sign revocation-list documents published at ``{origin}/.well-known/governance-revocations.json``. The list MAY be serialized in compact form (``header.payload.signature``) or JWS general JSON serialization (an object with ``payload`` and ``signatures[]``). -Both carry the same three fields after decoding. -This module intentionally does not pull in ``pyjwt`` / ``authlib``. AdCP -has a narrow allowed-alg set (``EdDSA``, ``ES256``) and we already own -the underlying crypto primitives for RFC 9421, so a ~120-line parser -over the existing ``verify_signature`` is both leaner and auditable. - -The signature base for compact JWS is:: - - ASCII(BASE64URL(protected_header)) || "." || ASCII(BASE64URL(payload)) - -— exactly what ``verify_signature`` expects, given the JWS-to-RFC-9421 -algorithm mapping below. +``joserfc`` does the JOSE work: base64url and header decoding, header +parameter validation, the JWS signing input, JWK import, key ``use`` / +``key_ops`` checks, ES256 raw ``r || s`` handling, and the signature check. +This module keeps only the AdCP profile on top of it: + +* ``alg`` is ``EdDSA`` (Ed25519 only) or ``ES256``; checked before any key + lookup, never left to library defaults (security.mdx checklist step 2); +* ``typ`` byte-equals the caller's expected value (step 3); +* ``kid`` is a non-empty protected-header string; the key comes from the + caller's resolver; +* ``crit`` lists no extensions: the revocation-list profile defines none; +* the general JSON form carries exactly one signature, and only its + protected header is honored; +* the protected header and payload are strict JSON (see + :func:`_decode_json_object`). """ from __future__ import annotations -import binascii -import json +import sys from typing import Any -from adcp.signing.crypto import ( - ALG_ED25519, - ALG_ES256, - b64url_decode, - public_key_from_jwk, - verify_signature, +from joserfc import jws +from joserfc.errors import ( + BadSignatureError, + InvalidKeyCurveError, + JoseError, + KeyParameterError, + UnsupportedKeyAlgorithmError, ) +from joserfc.jwa import EdDSAAlgorithm, ESAlgorithm, JWSAlgModel +from joserfc.jwk import ECKey, OKPKey +from joserfc.jws import JWSRegistry +from joserfc.util import urlsafe_b64decode, urlsafe_b64encode + +from adcp.signing._strict_json import StrictJsonError, parse_strict_json +from adcp.signing.crypto import ALG_ED25519, ALG_ES256 from adcp.signing.jwks import AsyncJwksResolver, JwksResolver -# JWS uses RFC 7518 algorithm names; RFC 9421 uses the IANA HTTP Signature -# names. We convert at the JWS boundary so the rest of the module speaks -# the internal alg vocabulary already used by our crypto primitives. +# JWS (RFC 7518) alg names mapped to the RFC 9421 names the rest of the SDK +# uses. Kept as public API; its keys are the profile's allowed algs. JWS_ALG_TO_INTERNAL: dict[str, str] = { "EdDSA": ALG_ED25519, "ES256": ALG_ES256, } ALLOWED_JWS_ALGS: frozenset[str] = frozenset(JWS_ALG_TO_INTERNAL.keys()) +# RFC 7518 section 6.2.1.2: a P-256 coordinate is exactly 32 bytes. +_P256_COORDINATE_BYTES = 32 + class JwsError(Exception): """Base class for JWS parse/verify failures.""" @@ -61,28 +73,80 @@ class JwsSignatureInvalidError(JwsError): """The JWS signature did not verify against the resolved key.""" -def _decode_protected_header(b64_header: str) -> dict[str, Any]: - try: - raw = b64url_decode(b64_header) - except (ValueError, binascii.Error) as exc: - raise JwsMalformedError(f"protected header is not valid base64url: {exc}") from exc +class _Ed25519EdDSA(EdDSAAlgorithm): + """RFC 8037 ``EdDSA``, pinned to Ed25519. + + joserfc's ``EdDSA`` also accepts Ed448 keys, which the AdCP profile does + not. It also warns that RFC 9864 deprecates the name, but AdCP mandates + ``EdDSA`` on the wire, so the warning is dropped. + """ + + # joserfc types this ``str`` on EdDSAAlgorithm; its base model allows None. + security_warning = None # type: ignore[assignment] + + def check_key(self, key: Any) -> None: + key.check_use("sig") + if not isinstance(key, OKPKey) or key.curve_name != "Ed25519": + raise InvalidKeyCurveError("AdCP EdDSA requires an Ed25519 OKP key") + # A JWK may advertise RFC 9864's fully-specified "Ed25519" for the same + # key. The verifier accepted that before joserfc, so it still does. + designed_alg = key.get("alg") + if designed_alg not in (None, "EdDSA", "Ed25519"): + raise UnsupportedKeyAlgorithmError( + f"This key is designed for algorithm '{designed_alg}'" + ) + + +class _AdcpJwsRegistry(JWSRegistry): + """joserfc registry that knows only the AdCP profile algorithms. + + The size caps are lifted. The fetch layer already bounds the document + (``max_body_bytes``, 1 MiB by default and configurable). joserfc's + smaller default caps (512-byte header, 128 kB payload) would reject + revocation lists that the configured limit admits. + """ + + algorithms: dict[str, JWSAlgModel] = { + "EdDSA": _Ed25519EdDSA(), + "ES256": ESAlgorithm("ES256", "P-256", 256), + } + recommended: list[str] = [] + max_header_length = sys.maxsize + max_payload_length = sys.maxsize + max_signature_length = sys.maxsize + + +def _b64url_decode(segment: str) -> bytes: + """RFC 7515 base64url: URL-safe alphabet, no padding, canonical tail bits.""" + return urlsafe_b64decode(segment.encode("ascii")) + + +def _decode_json_object(raw: bytes, what: str) -> dict[str, Any]: + """Parse ``raw`` as a strict JSON object. + + joserfc parses JSON with ``json.loads``. That keeps the last of two + duplicate member names and accepts ``NaN`` and lone surrogates. The bytes + are signed, not their reading, so two verifiers could disagree about what + the same signed list says. For example, a second ``revoked_kids`` could + erase a revocation for one of them. RFC 7515 section 5.2 lets a recipient + reject duplicate header names, so both header and payload are re-parsed + with the SDK's strict parser. + """ try: - header = json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise JwsMalformedError(f"protected header is not valid JSON: {exc}") from exc - if not isinstance(header, dict): - raise JwsMalformedError("protected header is not a JSON object") - return header + value = parse_strict_json(raw) + except StrictJsonError as exc: + raise JwsMalformedError(f"{what} is not valid JSON: {exc.reason}") from exc + if not isinstance(value, dict): + raise JwsMalformedError(f"{what} is not a JSON object") + return value def parse_compact_jws(token: str) -> tuple[str, str, bytes]: """Split a compact JWS into ``(b64_header, b64_payload, signature bytes)``. - Returns the header and payload as the ORIGINAL base64url substrings — - not decoded and re-encoded — so the verifier uses the exact bytes that - the signer hashed. This matters because ``urlsafe_b64decode`` is - lenient (accepts ``+``/``/`` and padding); round-tripping through it - can produce different bytes than the wire form. + Returns the header and payload as the ORIGINAL base64url substrings, not + decoded and re-encoded, so the verifier checks the exact bytes the signer + signed. """ if not isinstance(token, str): raise JwsMalformedError("compact JWS must be a string") @@ -95,19 +159,19 @@ def parse_compact_jws(token: str) -> tuple[str, str, bytes]: if not b64_header or not b64_payload or not b64_signature: raise JwsMalformedError("compact JWS has an empty segment") try: - signature = b64url_decode(b64_signature) - except (ValueError, binascii.Error) as exc: + signature = _b64url_decode(b64_signature) + except ValueError as exc: raise JwsMalformedError(f"compact JWS signature is not valid base64url: {exc}") from exc return b64_header, b64_payload, signature def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: - """Extract the first signature from a JWS general JSON serialization. + """Extract the single signature from a JWS general JSON serialization. - Returns ``(b64_header, b64_payload, signature_bytes)``. AdCP revocation - lists are signed by a single operator key, so we only honor the first - entry in ``signatures[]``. A list with multiple signatures is malformed - for this profile. + Returns ``(b64_header, b64_payload, signature_bytes)``. A single operator + key signs an AdCP revocation list, so this profile treats a list with more + than one signature as malformed. An unprotected ``header`` member is + ignored: every parameter the profile reads must be integrity-protected. """ if not isinstance(doc, dict): raise JwsMalformedError("JWS general JSON document must be an object") @@ -132,8 +196,8 @@ def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: if not isinstance(b64_payload, str): raise JwsMalformedError("JWS 'payload' must be a base64url string") try: - signature = b64url_decode(b64_signature) - except (ValueError, binascii.Error) as exc: + signature = _b64url_decode(b64_signature) + except ValueError as exc: raise JwsMalformedError(f"JWS signature is not valid base64url: {exc}") from exc return b64_header, b64_payload, signature @@ -144,24 +208,27 @@ def _validate_header( expected_typ: str, allowed_algs: frozenset[str], ) -> tuple[str, str]: - """Parse + validate the protected header; return ``(internal_alg, kid)``. + """Apply the AdCP header profile; return ``(jws_alg, kid)``. - Shared by sync and async verify paths — everything here is pure CPU. + Runs before key resolution, so a token outside the profile never causes a + JWKS lookup. Shared by the sync and async paths. Everything here is pure + CPU. """ - header = _decode_protected_header(b64_protected) + try: + raw = _b64url_decode(b64_protected) + except ValueError as exc: + raise JwsMalformedError(f"protected header is not valid base64url: {exc}") from exc + header = _decode_json_object(raw, "protected header") alg = header.get("alg") - if not isinstance(alg, str) or alg == "none" or alg not in allowed_algs: + if not isinstance(alg, str) or alg not in allowed_algs or alg not in JWS_ALG_TO_INTERNAL: raise JwsMalformedError( f"JWS alg {alg!r} not allowed; permitted values: {sorted(allowed_algs)}" ) - internal_alg = JWS_ALG_TO_INTERNAL[alg] typ = header.get("typ") if typ != expected_typ: - raise JwsMalformedError( - f"JWS typ {typ!r} does not match expected {expected_typ!r}" - ) + raise JwsMalformedError(f"JWS typ {typ!r} does not match expected {expected_typ!r}") crit = header.get("crit") if crit is not None and (not isinstance(crit, list) or len(crit) > 0): @@ -171,7 +238,34 @@ def _validate_header( if not isinstance(kid, str) or not kid: raise JwsMalformedError("JWS protected header must include a non-empty 'kid'") - return internal_alg, kid + return alg, kid + + +def _import_jwk(jwk: dict[str, Any]) -> OKPKey | ECKey: + """Import the resolved JWK as an Ed25519 or P-256 public key. + + An unusable JWK raises ``ValueError``, not a :class:`JwsError`. That is the + exception the verifier raised before joserfc, so callers see no change. + """ + kty = jwk.get("kty") + crv = jwk.get("crv") + try: + if kty == "OKP" and crv == "Ed25519": + return OKPKey.import_key(jwk) + if kty == "EC" and crv == "P-256": + # joserfc accepts a P-256 coordinate with leading zero bytes. + # RFC 7518 section 6.2.1.2 fixes the length, so check it here. + for name in ("x", "y"): + coordinate = jwk.get(name) + if ( + not isinstance(coordinate, str) + or len(_b64url_decode(coordinate)) != _P256_COORDINATE_BYTES + ): + raise ValueError(f"P-256 JWK {name} must be {_P256_COORDINATE_BYTES} bytes") + return ECKey.import_key(jwk) + except (JoseError, ValueError, TypeError, KeyError) as exc: + raise ValueError(f"unusable {kty}/{crv} JWK: {exc}") from exc + raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") def _verify_signature_and_decode_payload( @@ -180,37 +274,32 @@ def _verify_signature_and_decode_payload( b64_payload: str, signature: bytes, jwk: dict[str, Any], - internal_alg: str, + alg: str, kid: str, + allowed_algs: frozenset[str], ) -> dict[str, Any]: - """Run the cryptographic verify + decode the payload as JSON. + """Verify with joserfc, then decode the payload as a strict JSON object. - Signing input uses the ORIGINAL base64url strings — don't decode- - then-re-encode, since ``urlsafe_b64decode`` is lenient and a - round-trip can produce different bytes than the wire form. + The header and payload segments go to joserfc exactly as received, so the + signing input is the wire bytes. """ - signing_input = (b64_protected + "." + b64_payload).encode("ascii") - - public_key = public_key_from_jwk(jwk) - if not verify_signature( - alg=internal_alg, - public_key=public_key, - signature_base=signing_input, - signature=signature, - ): - raise JwsSignatureInvalidError(f"signature did not verify for kid {kid!r}") - - try: - payload_bytes = b64url_decode(b64_payload) - except (ValueError, binascii.Error) as exc: - raise JwsMalformedError(f"JWS payload is not valid base64url: {exc}") from exc + key = _import_jwk(jwk) + b64_signature = urlsafe_b64encode(signature).decode("ascii") + token = f"{b64_protected}.{b64_payload}.{b64_signature}" + registry = _AdcpJwsRegistry(algorithms=allowed_algs, strict_check_header=False) try: - decoded_payload = json.loads(payload_bytes.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise JwsMalformedError(f"JWS payload is not valid JSON: {exc}") from exc - if not isinstance(decoded_payload, dict): - raise JwsMalformedError("JWS payload is not a JSON object") - return decoded_payload + verified = jws.deserialize_compact(token, key, registry=registry) + except BadSignatureError as exc: + raise JwsSignatureInvalidError(f"signature did not verify for kid {kid!r}") from exc + except (KeyParameterError, InvalidKeyCurveError) as exc: + # The key cannot produce this alg: wrong type or curve, or its use, + # key_ops, or alg forbids verifying it. The signature cannot verify. + raise JwsSignatureInvalidError( + f"key for kid {kid!r} cannot verify a {alg} signature: {exc}" + ) from exc + except JoseError as exc: + raise JwsMalformedError(f"JWS is malformed: {exc}") from exc + return _decode_json_object(verified.payload, "JWS payload") def verify_detached_jws( @@ -230,15 +319,13 @@ def verify_detached_jws( 3. Reject if ``typ`` is not exactly ``expected_typ`` (byte equality, no normalization — matches the AdCP spec). 4. Resolve ``kid`` via ``jwks_resolver``; reject unknown. - 5. Verify the detached signature against the original b64 strings. + 5. Verify the signature against the original b64 strings. 6. Decode the payload as JSON and return it. - Any failure raises a :class:`JwsError` subclass. The caller maps these + Any JWS failure raises a :class:`JwsError` subclass. The caller maps these to transport-error codes (e.g. ``request_signature_revocation_stale``). """ - internal_alg, kid = _validate_header( - b64_protected, expected_typ=expected_typ, allowed_algs=allowed_algs - ) + alg, kid = _validate_header(b64_protected, expected_typ=expected_typ, allowed_algs=allowed_algs) jwk = jwks_resolver(kid) if jwk is None: raise JwsUnknownKeyError(f"no JWK for kid {kid!r}") @@ -247,8 +334,9 @@ def verify_detached_jws( b64_payload=b64_payload, signature=signature, jwk=jwk, - internal_alg=internal_alg, + alg=alg, kid=kid, + allowed_algs=allowed_algs, ) @@ -266,9 +354,7 @@ async def averify_detached_jws( Awaits an :class:`AsyncJwksResolver` on the ``kid`` lookup; all other work is pure CPU and runs inline. """ - internal_alg, kid = _validate_header( - b64_protected, expected_typ=expected_typ, allowed_algs=allowed_algs - ) + alg, kid = _validate_header(b64_protected, expected_typ=expected_typ, allowed_algs=allowed_algs) jwk = await jwks_resolver(kid) if jwk is None: raise JwsUnknownKeyError(f"no JWK for kid {kid!r}") @@ -277,8 +363,19 @@ async def averify_detached_jws( b64_payload=b64_payload, signature=signature, jwk=jwk, - internal_alg=internal_alg, + alg=alg, kid=kid, + allowed_algs=allowed_algs, + ) + + +def _parse_document(doc: str | dict[str, Any]) -> tuple[str, str, bytes]: + if isinstance(doc, str): + return parse_compact_jws(doc) + if isinstance(doc, dict): + return parse_general_json_jws(doc) + raise JwsMalformedError( + "JWS document must be a compact string or JSON general-serialization object" ) @@ -294,14 +391,7 @@ def verify_jws_document( Dispatches to the right parser based on the input shape, then calls :func:`verify_detached_jws`. Returns the verified payload dict. """ - if isinstance(doc, str): - b64_header, b64_payload, signature = parse_compact_jws(doc) - elif isinstance(doc, dict): - b64_header, b64_payload, signature = parse_general_json_jws(doc) - else: - raise JwsMalformedError( - "JWS document must be a compact string or JSON general-serialization object" - ) + b64_header, b64_payload, signature = _parse_document(doc) return verify_detached_jws( b64_protected=b64_header, b64_payload=b64_payload, @@ -324,14 +414,7 @@ async def averify_jws_document( Accepts an :class:`AsyncJwksResolver`; parsing and signature verify are pure CPU and run inline. """ - if isinstance(doc, str): - b64_header, b64_payload, signature = parse_compact_jws(doc) - elif isinstance(doc, dict): - b64_header, b64_payload, signature = parse_general_json_jws(doc) - else: - raise JwsMalformedError( - "JWS document must be a compact string or JSON general-serialization object" - ) + b64_header, b64_payload, signature = _parse_document(doc) return await averify_detached_jws( b64_protected=b64_header, b64_payload=b64_payload, diff --git a/src/adcp/signing/keygen.py b/src/adcp/signing/keygen.py index 6807b02a9..76fb49b47 100644 --- a/src/adcp/signing/keygen.py +++ b/src/adcp/signing/keygen.py @@ -48,85 +48,66 @@ import unicodedata from datetime import datetime, timezone from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, cast from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from joserfc.jwk import ECKey, OKPKey -from adcp.signing.crypto import ALG_ED25519, ALG_ES256, b64url_encode, load_private_key_pem +from adcp.signing.crypto import ALG_ED25519, ALG_ES256, load_private_key_pem +# Key generation, PKCS#8 PEM export and RFC 7517 / 8037 public JWK export are +# delegated to joserfc. What stays here is AdCP policy: the curve allow-list, +# the JWS ``alg`` each curve is published under, and the AdCP-specific JWK +# members (``use``, ``key_ops``, ``adcp_use``, ``kid``). -def _encryption_algorithm( - passphrase: bytes | None, -) -> serialization.KeySerializationEncryption: - if passphrase is None: - return serialization.NoEncryption() - return serialization.BestAvailableEncryption(passphrase) +_ADCP_USE_VALUES = ("request-signing", "webhook-signing") +# JWS ``alg`` (RFC 7518 / RFC 8037) published for each AdCP-allowed curve. Not +# the RFC 9421 ``alg`` used in ``Signature-Input``. +_JWS_ALG_BY_CURVE = {"Ed25519": "EdDSA", "P-256": "ES256"} -_ADCP_USE_VALUES = ("request-signing", "webhook-signing") +def _adcp_public_jwk(key: OKPKey | ECKey, *, kid: str, adcp_use: str) -> dict[str, Any]: + """Public JWK for ``key`` with the AdCP members added. -def _public_jwk_ed25519( - public_key: ed25519.Ed25519PublicKey, *, kid: str, adcp_use: str -) -> dict[str, Any]: - x = public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) + joserfc exports the public members only (``as_dict(private=False)`` + drops ``d``); member order is kept stable for the CLI's printed JWKS. + """ + public: dict[str, Any] = dict(key.as_dict(private=False)) + crv = public.pop("crv") return { - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", + "kty": public.pop("kty"), + "crv": crv, + "alg": _JWS_ALG_BY_CURVE[crv], "use": "sig", "key_ops": ["verify"], "adcp_use": adcp_use, "kid": kid, - "x": b64url_encode(x), + **public, } -def _public_jwk_es256( - public_key: ec.EllipticCurvePublicKey, *, kid: str, adcp_use: str -) -> dict[str, Any]: - numbers = public_key.public_numbers() - return { - "kty": "EC", - "crv": "P-256", - "alg": "ES256", - "use": "sig", - "key_ops": ["verify"], - "adcp_use": adcp_use, - "kid": kid, - "x": b64url_encode(numbers.x.to_bytes(32, "big")), - "y": b64url_encode(numbers.y.to_bytes(32, "big")), - } +def _private_pem(key: OKPKey | ECKey, passphrase: bytes | None) -> bytes: + # joserfc serializes as PKCS#8, using BestAvailableEncryption when a + # password is given. Its ``password`` is annotated ``str``, but the value + # goes through ``joserfc.util.to_bytes``, which returns ``bytes`` unchanged, + # so a non-UTF-8 passphrase stays byte-exact. + return key.as_pem(private=True, password=cast("str | None", passphrase)) def generate_ed25519( kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing" ) -> tuple[bytes, dict[str, Any]]: - private = ed25519.Ed25519PrivateKey.generate() - pem = private.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=_encryption_algorithm(passphrase), - ) - jwk = _public_jwk_ed25519(private.public_key(), kid=kid, adcp_use=adcp_use) - return pem, jwk + key = OKPKey.generate_key("Ed25519", private=True) + return _private_pem(key, passphrase), _adcp_public_jwk(key, kid=kid, adcp_use=adcp_use) def generate_es256( kid: str, passphrase: bytes | None = None, adcp_use: str = "request-signing" ) -> tuple[bytes, dict[str, Any]]: - private = ec.generate_private_key(ec.SECP256R1()) - pem = private.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=_encryption_algorithm(passphrase), - ) - jwk = _public_jwk_es256(private.public_key(), kid=kid, adcp_use=adcp_use) - return pem, jwk + key = ECKey.generate_key("P-256", private=True) + return _private_pem(key, passphrase), _adcp_public_jwk(key, kid=kid, adcp_use=adcp_use) def pem_to_adcp_jwk( @@ -208,8 +189,8 @@ def pem_to_adcp_jwk( public_key = load_private_key_pem(pem, password=password).public_key() if isinstance(public_key, ed25519.Ed25519PublicKey): - return _public_jwk_ed25519(public_key, kid=kid, adcp_use=purpose) - return _public_jwk_es256(public_key, kid=kid, adcp_use=purpose) + return _adcp_public_jwk(OKPKey.import_key(public_key), kid=kid, adcp_use=purpose) + return _adcp_public_jwk(ECKey.import_key(public_key), kid=kid, adcp_use=purpose) def _default_kid(alg: str) -> str: @@ -264,8 +245,8 @@ def generate_signing_keypair( rely on filesystem perms (the CLI writes mode 0600). **Passphrase lifecycle.** CPython cannot zero ``bytes``. Once - passed here, the buffer is consumed by ``cryptography`` and - then released to GC; there's no ``zeroize`` step. Callers + passed here, the buffer is handed to joserfc (which encrypts via + ``cryptography``) and then released to GC; there's no ``zeroize`` step. Callers handling long-lived credentials should source the passphrase from a secret manager per call rather than hold a Python literal in process memory. diff --git a/src/adcp/signing/revocation_fetcher.py b/src/adcp/signing/revocation_fetcher.py index 638f451e0..2618a4cf1 100644 --- a/src/adcp/signing/revocation_fetcher.py +++ b/src/adcp/signing/revocation_fetcher.py @@ -95,8 +95,16 @@ class RevocationListFetchError(Exception): Mapped to ``request_signature_revocation_stale`` at the verifier edge when it occurs past the grace window; within the grace window the cached list is served and the error is logged at debug level. + + ``status_code`` is the HTTP status when the origin answered with one + other than 200 or 304. A Web Bot Auth identity that answers 404 or 410 + serves no revocation list. """ + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + class RevocationListParseError(Exception): """The fetched document is malformed, fails schema validation, or @@ -217,7 +225,9 @@ def _fetch_result_from_response( not_modified=True, ) if status_code != 200: - raise RevocationListFetchError(f"revocation list {uri!r} returned HTTP {status_code}") + raise RevocationListFetchError( + f"revocation list {uri!r} returned HTTP {status_code}", status_code=status_code + ) etag = response_headers.get("ETag") last_modified = _sanitize_last_modified(response_headers.get("Last-Modified")) @@ -514,6 +524,13 @@ 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 retains_revoked_key(self, keyid: str) -> bool: + """Whether the last verified list revoked ``keyid``, however stale it is. + + A stale or unreadable list never un-revokes a key it revoked. + """ + return self._current_list is not None and self._current_list.is_revoked(keyid) + def _handle_not_modified(self, *, now_mono: float) -> None: """Record a successful conditional request without changing signed freshness. diff --git a/src/adcp/signing/standard_webhooks.py b/src/adcp/signing/standard_webhooks.py index 24fdd3ce9..b8b7f55a8 100644 --- a/src/adcp/signing/standard_webhooks.py +++ b/src/adcp/signing/standard_webhooks.py @@ -1,12 +1,12 @@ """Standard Webhooks v1 signing + verifying. -Pure-Python implementation of the standardwebhooks.com v1 spec -(https://www.standardwebhooks.com/) so adopters can deliver webhooks to -buyers running Svix, Resend, or any other Standard-Webhooks verifier -without custom code, and verify Standard-Webhooks-signed inbound -webhooks too. +Thin AdCP wrappers over the specification's own reference package, +:mod:`standardwebhooks` (https://www.standardwebhooks.com/), so adopters +can deliver webhooks to buyers running Svix, Resend, or any other +Standard-Webhooks verifier without custom code, and verify +Standard-Webhooks-signed inbound webhooks too. -Wire format (per spec): +Wire format (per spec, implemented by :class:`standardwebhooks.Webhook`): * ``webhook-id`` — a unique identifier for this delivery (UUID is fine). * ``webhook-timestamp`` — Unix epoch seconds, string. @@ -14,22 +14,29 @@ ``v1,`` where the base64 value is ``HMAC-SHA256(secret, f"{webhook_id}.{webhook_timestamp}.{body}")``. -Secrets are typically distributed in the canonical ``whsec_`` form; -:func:`decode_secret` strips the prefix and base64-decodes to raw bytes. -The signing/verifying functions take raw bytes — pass the decoded form. - -Why standalone (not depending on the ``standardwebhooks`` PyPI package): -~80 LOC. The package's dependency footprint is not worth carrying for an -algorithm this size, and a pure-Python implementation removes one -moving part from our supply chain. +The library computes the signature (:meth:`standardwebhooks.Webhook.sign`). +This module keeps only what the SDK's API promises on top of it: + +* an injectable clock and tolerance. ``Webhook.verify`` reads the wall + clock with a fixed five-minute window and offers no public way to change + it, so verification calls ``Webhook.sign`` and matches the ``v1`` tokens + itself, in constant time, until the library exposes one; +* strict secret decoding (:func:`decode_secret`) that fails fast on a + corrupted ``whsec_`` value instead of deriving a different key; +* a canonical-integer ``webhook-timestamp`` (the library accepts floats + and signs the floored value, not the bytes on the wire); +* raw-bytes bodies and :class:`StandardWebhookError` for every failure. """ from __future__ import annotations import base64 -import hashlib import hmac from collections.abc import Mapping +from datetime import datetime, timezone + +from standardwebhooks import Webhook +from standardwebhooks.webhooks import EmptyWebhookSecretError # Header names per spec — case-insensitive on the wire; we match # case-folded. @@ -42,9 +49,8 @@ # generic-high-entropy detector pattern-matches ``whsec_`` # even in obvious test files; importing the constant keeps the literal # in this single source-of-truth module). -SECRET_PREFIX = "whsec_" +SECRET_PREFIX = "whsec_" # nosec B105 - the public secret prefix, not a secret _SECRET_PREFIX = SECRET_PREFIX # backward-compat alias for in-module use -_SIGNATURE_VERSION = "v1" _DEFAULT_TOLERANCE_SECONDS = 300 @@ -52,6 +58,37 @@ class StandardWebhookError(Exception): """Raised when Standard Webhooks signing or verification fails.""" +def _to_datetime(timestamp: int, *, header: str | None = None) -> datetime: + try: + return datetime.fromtimestamp(timestamp, tz=timezone.utc) + except (OverflowError, OSError, ValueError) as exc: + if header is not None: + raise StandardWebhookError(f"invalid webhook-timestamp {header!r}") from exc + raise StandardWebhookError("timestamp is out of range") from exc + + +def _webhook(secret: bytes) -> Webhook: + # The library base64-decodes ``str`` secrets leniently; the SDK's + # contract is raw key bytes (decode ``whsec_`` values with + # :func:`decode_secret`), so refuse strings rather than reinterpret. + if not isinstance(secret, (bytes, bytearray)): + raise TypeError("secret must be raw key bytes; decode whsec_ secrets with decode_secret()") + try: + return Webhook(bytes(secret)) + except EmptyWebhookSecretError as exc: + raise StandardWebhookError("secret must be non-empty") from exc + + +def _body_text(body: bytes) -> str: + # The library signs ``str`` and re-encodes it as UTF-8. For valid + # UTF-8 that round trip is byte-exact; anything else cannot be + # represented, so reject it rather than sign different bytes. + try: + return bytes(body).decode("utf-8") + except UnicodeDecodeError as exc: + raise StandardWebhookError("body must be UTF-8") from exc + + def decode_secret(secret: str) -> bytes: """Decode a ``whsec_`` secret to raw HMAC key bytes. @@ -63,6 +100,12 @@ def decode_secret(secret: str) -> bytes: Accepts both with-prefix (``whsec_AAAA...``) and without — pass-through for already-decoded callers. Padding is permissive (Svix-issued secrets sometimes lack ``=`` padding). + + Kept rather than delegated: ``standardwebhooks.Webhook`` decodes with + non-validating base64, which silently drops stray characters and + yields a different key, so a corrupted secret would only surface as + every delivery failing verification at the buyer. This decoder + rejects it at configuration time. """ if not isinstance(secret, str) or not secret: raise StandardWebhookError("secret must be a non-empty string") @@ -72,7 +115,7 @@ def decode_secret(secret: str) -> bytes: padding = "=" * (-len(payload) % 4) try: # binascii.Error subclasses ValueError; one catch covers both. - return base64.b64decode(payload + padding, validate=True) + key = base64.b64decode(payload + padding, validate=True) except ValueError as exc: # Do NOT include the underlying exception message in the # StandardWebhookError text — the binascii error string echoes @@ -82,6 +125,11 @@ def decode_secret(secret: str) -> bytes: # for the operator to diagnose (chained via ``from exc`` for # debug-mode introspection). raise StandardWebhookError("secret is not valid base64") from exc + if not key: + # A bare ``whsec_`` decodes to zero bytes, which the library + # refuses as a key; fail here, at configuration time. + raise StandardWebhookError("secret must be non-empty") + return key def sign_standard_webhook( @@ -98,19 +146,19 @@ def sign_standard_webhook( The signature header value is ``v1,`` — multiple versions can coexist space-separated, but senders only emit ``v1`` (the only - version the spec defines). + symmetric version the spec defines). """ if not msg_id: raise StandardWebhookError("msg_id must be non-empty") - if not isinstance(timestamp, int): + # ``bool`` is an ``int`` subclass; ``str(True)`` would put "True" on + # the wire while the library signs "1". + if not isinstance(timestamp, int) or isinstance(timestamp, bool): raise StandardWebhookError("timestamp must be an int (epoch seconds)") - message = f"{msg_id}.{timestamp}.".encode() + body - digest = hmac.new(secret, message, hashlib.sha256).digest() - encoded = base64.b64encode(digest).decode("ascii") + signature = _webhook(secret).sign(msg_id, _to_datetime(timestamp), _body_text(body)) return { HEADER_ID: msg_id, HEADER_TIMESTAMP: str(timestamp), - HEADER_SIGNATURE: f"{_SIGNATURE_VERSION},{encoded}", + HEADER_SIGNATURE: signature, } @@ -131,7 +179,8 @@ def verify_standard_webhook( ``headers`` may be any case-insensitive mapping. Multiple v1 signatures in ``webhook-signature`` are accepted (per spec, for key - rotation) — verification succeeds if any one of them matches. + rotation) — verification succeeds if any one of them matches. Tokens + with other versions (``v1a``, future schemes) are ignored. """ header_map = {str(k).lower(): str(v) for k, v in headers.items()} msg_id = header_map.get(HEADER_ID) @@ -142,10 +191,18 @@ def verify_standard_webhook( "missing webhook-id, webhook-timestamp, or webhook-signature header" ) + # Pre-checks the library cannot express: the caller's clock and + # tolerance, and a canonical integer timestamp. The library parses + # the header as a float and signs ``floor(value)``, so "+1700000000" + # or "1700000000.9" would be verified against bytes that differ from + # the header on the wire. try: ts_int = int(ts_value) except ValueError as exc: raise StandardWebhookError(f"invalid webhook-timestamp {ts_value!r}") from exc + if str(ts_int) != ts_value: + raise StandardWebhookError(f"invalid webhook-timestamp {ts_value!r}") + _to_datetime(ts_int, header=ts_value) skew = abs(now - ts_int) if skew > tolerance_seconds: @@ -153,19 +210,21 @@ def verify_standard_webhook( f"timestamp skew {skew:.0f}s exceeds tolerance {tolerance_seconds}s" ) - message = f"{msg_id}.{ts_value}.".encode() + body - expected = base64.b64encode(hmac.new(secret, message, hashlib.sha256).digest()).decode("ascii") - - # Spec allows multiple space-separated tokens for key rotation: - # "v1,sig1 v1,sig2". Accept a match against any v1 token; ignore - # unknown versions (forward-compat with future signature schemes). + # The signature itself comes from the library (Webhook.sign). Matching is + # done here with public API only: Webhook.verify reads the wall clock with + # a fixed five-minute window that cannot be configured, so it cannot honor + # the caller's ``now`` and ``tolerance_seconds`` (tracked upstream; see the + # migration notes). The comparison is the spec's: any ``v1`` token, + # exact base64, constant time; other versions and malformed tokens are + # skipped. + expected = _webhook(secret).sign(msg_id, _to_datetime(ts_int), _body_text(body)) + expected_value = expected.partition(",")[2] for token in sig_header.split(" "): version, _, value = token.partition(",") - if version != _SIGNATURE_VERSION or not value: + if version != "v1" or not value: continue - if hmac.compare_digest(expected, value): + if hmac.compare_digest(expected_value.encode("ascii"), value.encode("utf-8")): return - raise StandardWebhookError("no matching v1 signature") diff --git a/src/adcp/signing/trust.py b/src/adcp/signing/trust.py index 4e17470c5..7a49da851 100644 --- a/src/adcp/signing/trust.py +++ b/src/adcp/signing/trust.py @@ -32,13 +32,25 @@ class TrustRecordError(ValueError): - """A missing, invalid or unavailable trust record provides no authority.""" + """A trust record that provides no authority. + + Two kinds matter for a bound origin. *Not listed* + (:class:`TrustRecordMatchError`, :class:`TrustRecordAbsentError`): the + operator's last record read does not list the origin with the role, or + the operator has never published one. *Unavailable* + (:class:`TrustRecordUnavailableError`): the record cannot be read now, + including a 404/410 after the operator published one. A fetch failure is + never absence and never a removal: the last record read stays + authoritative. + """ class TrustRecordNotFoundError(TrustRecordError): - """A definitive 404/410 provides no current operator membership.""" + """Base of :class:`TrustRecordAbsentError`: a 404/410 before any 200. - operator_domain: str | None = None + A 404/410 from an operator that has served a record is a discovery + failure (:class:`TrustRecordUnavailableError`), never a removal. + """ class TrustRecordAbsentError(TrustRecordNotFoundError): @@ -46,11 +58,11 @@ class TrustRecordAbsentError(TrustRecordNotFoundError): class TrustRecordUnavailableError(TrustRecordError): - """A temporary outage cannot establish delisting or new spend authority.""" + """A record that cannot be read: an outage, never absence or a removal.""" class TrustRecordMatchError(TrustRecordError): - """A fetched record no longer lists the origin with its required role.""" + """The last record read does not list the origin with its required role.""" class _TrustRecordCapacityError(TrustRecordUnavailableError): @@ -194,9 +206,12 @@ def _validate_trust_record( 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") + if not reader: + # Authoring: publish grantees as registrable domains. Readers compare + # any grantee as its registrable domain instead of rejecting it. + 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) @@ -240,6 +255,13 @@ def preserve_posture(profiles: Any) -> None: @dataclass(frozen=True) class EvaluatedTrustRecord: + """A validated trust record for ``operator_domain``. + + ``stale_on_outage`` marks the last record read, returned during an outage + within the evaluator's outage-continuation bound; ``expires_at`` is then + the end of that bound. + """ + operator_domain: str source_url: str document: Mapping[str, Any] @@ -261,6 +283,26 @@ def agent(self, origin: str, *, role: str) -> Mapping[str, Any]: return dict(matches[0]) +@dataclass(frozen=True) +class TrustListing: + """The outcome of :meth:`TrustRecordEvaluator.check_listing`. + + ``status`` is ``"listed"``, ``"unlisted"`` (the last record read does not + list the origin with the role, including an empty ``agents[]``; the + operator never published a record; or the origin has no registrable + domain) or ``"unavailable"`` (the record cannot be read now, and the last + record read listed the origin, or there is none). ``within_grace`` marks + an outage within the seller's outage-continuation bound, when ``record`` + is the last record read, marked ``stale_on_outage``: non-spend calls may + continue under a binding; spend never does. + """ + + status: Literal["listed", "unlisted", "unavailable"] + record: EvaluatedTrustRecord | None = None + within_grace: bool = False + error: TrustRecordError | None = None + + @dataclass(frozen=True) class _TrustRecordFailure: error_type: type[TrustRecordError] @@ -268,12 +310,52 @@ class _TrustRecordFailure: retry_after: float -class TrustRecordEvaluator: - """Fetch one authoritative-location hop and cache no longer than revocation polling. +def _quota_group(domain: str) -> str: + from adcp.signing.etld import quota_key + + return quota_key(domain) or domain - 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. + +class TrustRecordEvaluator: + """Evaluate operator trust records at binding, grants and open onboarding. + + Never on the signature path. Derives ``https://{eTLD+1}/.well-known/trust.json``, + follows at most one ``authoritative_location`` hop with no HTTP redirects, + and caches a result no longer than the polling interval. + + Outage rules (security.mdx, Web Bot Auth identities): + + * A fetch failure is not absence. Any status other than 200 (a 404/410 + after the operator has served a record included), a transport error, an + invalid record, or a local capacity refusal is *unavailable*. Only a + 404/410 from an operator that has never served a 200 is absence + (:class:`TrustRecordAbsentError`). + * The last record read stays authoritative. If it does not list an origin + (operators retire agents by publishing an empty ``agents[]``), the + origin stays delisted through any later failure. + * Spend never continues on a stale record. Non-spend callers may opt in + (``allow_stale_on_outage``) to the last record read within + ``outage_continuation_seconds`` past its freshness: seller policy, + default four polling intervals, ``0`` disables. + + Which operators have served a record, and the last record read from each, + is authority history. It is kept in memory, bounded without eviction: + ``max_history_records`` operators overall and + ``max_history_records_per_operator`` per quota group (a registrable + domain; every tenant of a PSL PRIVATE hosting suffix counts together). + A new operator over quota is refused as unavailable before fetching, and + no other operator's history is lost. Losing this history (a restart, + another replica) turns a later 404 into absence and a known delisting into + an outage; public deployments must persist it durably. + + Fetches to an operator's own hosts are budgeted per ``(operator, host)`` + (``max_fetches_per_host``). ``authoritative_location`` hops to hosts + outside the operator's registrable domain share a separate, global + per-host budget (``max_foreign_fetches_per_host``), so a hop never spends + the target operator's own budget. Host cool-downs are scoped to + ``(operator, host)`` and never follow content errors. A budget refusal is + a retryable outage that is never cached as a failure. Custom fetchers + enforce SSRF policy. """ def __init__( @@ -283,113 +365,116 @@ def __init__( allow_private: bool = False, polling_interval_seconds: float = 60, max_records: int = 256, - max_history_records: int = 256, + max_history_records: int = 65536, + max_history_records_per_operator: int = 64, max_fetches_per_host: int = 4, + max_foreign_fetches_per_host: int = 16, + outage_continuation_seconds: float | None = None, 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 + limits = ( + max_records, + max_history_records, + max_history_records_per_operator, + max_fetches_per_host, + max_foreign_fetches_per_host, + ) + if not 0 < polling_interval_seconds <= 1800 or any( + type(limit) is not int or limit <= 0 for limit in limits ): raise ValueError("invalid trust cache bounds") + if outage_continuation_seconds is None: + outage_continuation_seconds = 4 * polling_interval_seconds + if outage_continuation_seconds < 0: + raise ValueError("outage_continuation_seconds must not be negative") self._fetcher, self._allow_private, self._interval = ( fetcher, allow_private, polling_interval_seconds, ) + self._outage_continuation = float(outage_continuation_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._max_history_per_operator = max_history_records_per_operator + # Authority history admission: one slot per eTLD+1. Pending owners + # reserve their slot before any response can establish 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._history_groups: dict[str, int] = {} + # A previously absent operator keeps its admission, so a hosted-tenant + # flood cannot disable its configured legacy policy. 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._max_foreign_fetches_per_host = max_foreign_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._foreign_inflight: dict[str, int] = {} self._host_failures: OrderedDict[tuple[str, str], float] = OrderedDict() + # Authority history: operators that served a 200, and the last valid + # record read from each. Never evicted. 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._last_record: dict[str, EvaluatedTrustRecord] = {} 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.""" + """The freshness cadence (the revocation polling interval).""" return self._interval + @property + def outage_continuation_seconds(self) -> float: + """Seller policy: how long past freshness a stale record serves non-spend calls.""" + return self._outage_continuation + 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) + def _stale_within_bound(self, last: EvaluatedTrustRecord) -> EvaluatedTrustRecord | None: + until = last.expires_at + self._outage_continuation + if self._outage_continuation <= 0 or self._clock() >= until: + return None + return EvaluatedTrustRecord( + last.operator_domain, + last.source_url, + copy.deepcopy(last.document), + until, + stale_on_outage=True, + revision=last.revision, + ) async def cached_record( self, origin_or_domain: str, *, role: str | None = None ) -> EvaluatedTrustRecord: - """Inspect latest policy during an outage, never fresh spend authority. + """The last record read, 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. + With ``role``, a last record that does not list the origin raises + :class:`TrustRecordMatchError` (a known delisting survives an outage). + Without a last record, or past the outage-continuation bound, raises + :class:`TrustRecordUnavailableError`. 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: + last = self._last_record.get(domain) + if last 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, - ) + last.agent(origin_or_domain, role=role) + stale = self._stale_within_bound(last) + if stale is None: + raise TrustRecordUnavailableError("trust record outage grace has passed") + return stale 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. - """ + """Whether every captured record is still the latest one read for its owner.""" async with self._lock: return all( - isinstance( - policy := self._last_policy.get(record.operator_domain), EvaluatedTrustRecord - ) - and policy.revision == record.revision + (latest := self._last_record.get(record.operator_domain)) is not None + and latest.revision == record.revision for record in records ) @@ -401,11 +486,12 @@ async def validate_snapshots( role: str | None = None, legacy_unpublished_domain: str | None = None, ) -> None: - """Recheck latest policy, revision and deadline before assigning authority. + """Recheck the latest record, 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. + ``origin_or_domain``/``role`` match the first record's latest agent + entry, even when another origin refreshed that owner's record. This + final guard never upgrades an older captured snapshot to a newer + approval. """ async with self._lock: if ( @@ -414,21 +500,15 @@ async def validate_snapshots( ): 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 + latest = self._last_record.get(record.operator_domain) + if latest is None: + raise TrustRecordUnavailableError("trust record history is unavailable") 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") + latest.agent(origin_or_domain, role=role) + if latest.revision != record.revision: + raise TrustRecordUnavailableError("trust record changed during evaluation") + if self._clock() >= record.expires_at: + raise TrustRecordUnavailableError("trust record expired during evaluation") async def evaluate( self, @@ -437,34 +517,80 @@ async def evaluate( role: str | None = None, allow_stale_on_outage: bool = False, ) -> EvaluatedTrustRecord: - """Require fresh policy unless an existing non-spend caller opts into grace.""" + """Return the operator's record; with ``role``, it must list the origin. + + Fresh unless the record cannot be read and ``allow_stale_on_outage`` + (a non-spend caller) opts into the last record read within the outage + bound. Whatever the caller, a last record that does not list the + origin raises :class:`TrustRecordMatchError`: a cap, an outage or a + 404 never turns a known delisting into an outage. + """ try: - return await self._evaluate_fresh(origin_or_domain, role=role) + record = await self._evaluate_fresh(origin_or_domain) except TrustRecordUnavailableError as outage: try: - cached = await self.cached_record(origin_or_domain, role=role) + stale = 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 allow_stale_on_outage: + raise history_error from outage + raise outage from None if not allow_stale_on_outage: - raise outage - return cached + raise outage from None + return stale + if role is not None: + record.agent(origin_or_domain, role=role) + return record - async def _evaluate_fresh( - self, origin_or_domain: str, *, role: str | None = None - ) -> EvaluatedTrustRecord: + async def check_listing(self, origin: str, *, role: str) -> TrustListing: + """Whether the origin's operator lists it with ``role``, under the outage rules.""" + try: + record = await self.evaluate(origin, role=role, allow_stale_on_outage=True) + except TrustRecordUnavailableError as exc: + return TrustListing("unavailable", error=exc) + except TrustRecordError as exc: + # Not listed, never published, or no registrable domain. + return TrustListing("unlisted", error=exc) + if record.stale_on_outage: + return TrustListing("unavailable", record=record, within_grace=True) + return TrustListing("listed", record=record) + + def _admit_history(self, domain: str) -> None: + """Reserve an authority-history slot for ``domain`` (lock held).""" + if domain in self._history_owners: + return + group = _quota_group(domain) + if ( + len(self._history_owners) >= self._max_history_records + or self._history_groups.get(group, 0) >= self._max_history_per_operator + ): + raise _TrustRecordCapacityError("trust history admission capacity exceeded") + self._history_owners.add(domain) + self._history_groups[group] = self._history_groups.get(group, 0) + 1 + + def _release_history(self, domain: str) -> None: + """Release a reservation that established no history (lock held).""" + if ( + domain in self._seen_200 + or domain in self._last_record + or domain in self._known_absent_owners + or domain not in self._history_owners + ): + return + self._history_owners.discard(domain) + group = _quota_group(domain) + remaining = self._history_groups.get(group, 1) - 1 + if remaining > 0: + self._history_groups[group] = remaining + else: + self._history_groups.pop(group, None) + + async def _evaluate_fresh(self, origin_or_domain: str) -> 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) @@ -473,7 +599,7 @@ async def _evaluate_fresh( if task is None: if len(self._pending) >= self._max_records: raise _TrustRecordCapacityError("trust discovery capacity exceeded") - self._history_owners.add(domain) + self._admit_history(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. @@ -489,18 +615,8 @@ async def _evaluate_fresh( 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, - ) + return replace(record, document=copy.deepcopy(record.document)) async def _refresh(self, domain: str, url: str, now: float) -> EvaluatedTrustRecord: try: @@ -509,7 +625,7 @@ async def _refresh(self, domain: str, url: str, now: float) -> EvaluatedTrustRec self._policy_revision += 1 record = replace(record, revision=self._policy_revision) self._cache[domain] = record - self._remember_policy(domain, record) + self._last_record[domain] = record self._cache.move_to_end(domain) self._failures.pop(domain, None) while len(self._cache) > self._max_records: @@ -522,19 +638,15 @@ async def _refresh(self, domain: str, url: str, now: float) -> EvaluatedTrustRec except Exception as exc: error = ( exc - if isinstance(exc, TrustRecordError) + if isinstance(exc, (TrustRecordUnavailableError, TrustRecordAbsentError)) else TrustRecordUnavailableError("trust record fetch failed") ) async with self._lock: - failure = _TrustRecordFailure(type(error), str(error), self._clock() + 60) - self._failures[domain] = failure + self._failures[domain] = _TrustRecordFailure( + type(error), str(error), self._clock() + 60 + ) 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) @@ -544,60 +656,67 @@ async def _refresh(self, domain: str, url: str, now: float) -> EvaluatedTrustRec 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) + self._release_history(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: + _validate_fetch_target(source) + except TrustRecordError as exc: + raise TrustRecordUnavailableError("invalid trust fetch target") from exc 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): + except (_TrustRecordBodyError, _TrustRecordReadError) as exc: # 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 TrustRecordUnavailableError("trust record body could not be read") from exc + except TrustRecordUnavailableError: 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") + # Including a 404/410 after the operator published a record: + # a discovery failure, never a removal. + raise TrustRecordUnavailableError( + f"trust record unavailable (HTTP {response.status})" + ) 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) + if len(response.body) > 2 * 1024 * 1024: + raise TrustRecordError("trust record is 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) + except TrustRecordError as exc: + # An invalid record is unreadable: the last record read stands. + raise TrustRecordUnavailableError("trust record is invalid") from exc 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") + raise TrustRecordUnavailableError( + "a second authoritative-location hop is forbidden" + ) source = pointer - raise TrustRecordError("trust record resolution failed") + raise TrustRecordUnavailableError("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.""" + """Share transport bytes per URL while each owner validates them separately.""" host = urlsplit(url).hostname or "" owner_host = (owner_domain, host) + foreign = registrable_domain(host) != owner_domain async with self._lock: now = self._clock() cached = self._responses.get(url) @@ -609,24 +728,28 @@ async def _fetch_response(self, url: str, *, owner_domain: str) -> tuple[float, 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 - ): + if len(self._response_pending) >= self._max_records: + raise _TrustRecordCapacityError("trust discovery capacity exceeded") + if foreign: + # Hops to another operator's host share one global budget + # per host, never the target operator's own budget. + if self._foreign_inflight.get(host, 0) >= self._max_foreign_fetches_per_host: + raise _TrustRecordCapacityError("trust fetch host capacity exceeded") + elif self._hosts_inflight.get(owner_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 = asyncio.create_task(self._refresh_response(url, owner_host, now, foreign)) 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 + if foreign: + self._foreign_inflight[host] = self._foreign_inflight.get(host, 0) + 1 + else: + self._hosts_inflight[owner_host] = self._hosts_inflight.get(owner_host, 0) + 1 return await asyncio.shield(task) async def _refresh_response( - self, url: str, owner_host: tuple[str, str], now: float + self, url: str, owner_host: tuple[str, str], now: float, foreign: bool ) -> tuple[float, TrustResponse]: try: response = await self._fetcher(url, allow_private=self._allow_private) @@ -659,21 +782,23 @@ async def _refresh_response( raise TrustRecordUnavailableError("trust record fetch failed") from exc if isinstance(exc, TrustRecordError): raise - raise TrustRecordError("trust record content is invalid") from exc + raise _TrustRecordBodyError("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 + if foreign: + host = owner_host[1] + remaining = self._foreign_inflight.get(host, 1) - 1 + if remaining > 0: + self._foreign_inflight[host] = remaining + else: + self._foreign_inflight.pop(host, None) else: - self._host_totals.pop(host, None) + remaining = self._hosts_inflight.get(owner_host, 1) - 1 + if remaining > 0: + self._hosts_inflight[owner_host] = remaining + else: + self._hosts_inflight.pop(owner_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.""" @@ -692,9 +817,19 @@ async def bind( migration_approved: bool = False, role: str = "adcp:buying", ) -> EvaluatedTrustRecord: - """Explicitly bind a verified origin after evaluating its operator record.""" + """Explicitly bind a verified origin after evaluating its operator record. + + The signer must carry a current revocation state + (:attr:`VerifiedSigner.revocation` ``== "current"``): an identity that + serves no revocation list, or whose list is stale or unchecked, stays + identity-established and cannot be bound. The record must currently + list the origin with ``role``, so an unlisted origin cannot be bound + again, whatever the onboarding mode. + """ if signer.profile != "web-bot-auth" or signer.agent_url is None: raise TrustRecordError("binding requires a verified Web Bot Auth identity") + if signer.revocation != "current": + raise TrustRecordError("binding requires the origin to serve a current revocation list") record = await self.evaluate(signer.agent_url, role=role) bindings.bind( signer.agent_url, @@ -751,31 +886,32 @@ class GrantRequest: countries: tuple[str, ...] +def _registrable(value: str) -> str | None: + """The registrable domain, or ``None`` for any value without one.""" + try: + return registrable_domain(value) + except ValueError: + return None + + 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.""" +def _unavailable(reason: str) -> GrantAuthorization: + """An outage: retryable SERVICE_UNAVAILABLE, never grant_absent.""" return GrantAuthorization( - False, - reason, - error_code=( - "SERVICE_UNAVAILABLE" - if isinstance(error, TrustRecordUnavailableError) - else "PERMISSION_DENIED" - ), - include_grant_absent_details=False, + False, reason, error_code="SERVICE_UNAVAILABLE", 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 _unlisted(reason: str) -> GrantAuthorization: + """No standing for the signer: PERMISSION_DENIED with no details.""" + return GrantAuthorization(False, reason, include_grant_absent_details=False) + + +def _instant(value: str) -> float: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() def _grant_matches( @@ -788,8 +924,11 @@ def _grant_matches( countries: frozenset[str], now: float, ) -> bool: - if grant["grantee"] != grantee or not _scope_covers(grant["scopes"], scope): + # Grantees compare as registrable domains under the pinned PSL snapshot. + if _registrable(grant["grantee"]) != grantee or not _scope_covers(grant["scopes"], scope): return False + # agents[] narrows the link that reaches the signer: the signer's + # canonical origin must be listed, or the chain ends at this link. agents = grant.get("agents") if agents is not None and not any(canonical_origin(agent["url"]) == origin for agent in agents): return False @@ -798,20 +937,59 @@ def _grant_matches( 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 + if "valid_from" in grant and now < _instant(grant["valid_from"]): + return False + if "valid_until" in grant and now >= _instant(grant["valid_until"]): + return False return True -class TrustGrantEvaluator: - """Evaluate direct or one-intermediary demand grants against the brand's record. +@dataclass(frozen=True) +class BrandHouse: + """The house a brand's verified brand.json names, and the brand's id there. - 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. + ``brand_id`` is the brand's child-brand id in the house's portfolio; grant + ``profiles.adcp.brands`` are checked against it. ``None`` keeps the + claim's own ``brand_id``. + """ + + domain: str + brand_id: str | None = None + + +HouseResolver = Callable[[str, "str | None"], Awaitable["BrandHouse | str | None"]] + + +class TrustGrantEvaluator: + """Evaluate demand-side grants (L2 §Grants in trust.json, DR-0025). + + * One record authorizes: the house's. ``resolve_house(brand_domain, + brand_id)`` returns the verified :class:`BrandHouse` the brand's + brand.json names (a house redirect, or a ``house_domain`` the house + reciprocates), with the brand's id in the house's portfolio, or just + the house domain. Without the hook, or when it returns ``None``, the + brand's own domain is the house. Grants in any other record are never + consulted. + * Domains compare as registrable domains under the pinned PSL snapshot. + Authority is implicit when ``account.operator``, the brand's domain and + the signer's operator share one. + * Otherwise a chain must reach the signer's operator with + ``account.operator`` on it: the house grants the signer's operator, + which is ``account.operator``; the brand is ``account.operator`` (a + self-operated brand) and its house grants the signer's operator; or the + house grants ``account.operator``, whose own record grants the signer's + operator. There is no third link. + * Every link must cover the scope, brand, countries and agent narrowing + (a narrowed link that does not list the signer ends the chain), and the + check time must fall inside every link's ``valid_from``/``valid_until`` + window, so effective authority is the intersection along the chain. + * A 200 from the house's trust.json makes it the only source of grants: + ``legacy_authorization`` (brand.json ``authorized_operators``) runs only + while the house has never published one. A record that cannot be read, + including a 404/410 after it was read, is never absence and never + ``grant_absent``: a non-spend claim (``allow_stale_on_outage``) is + evaluated on the last records read within the evaluator's outage bound, + and anything else gets the retryable ``SERVICE_UNAVAILABLE``. """ def __init__( @@ -821,21 +999,18 @@ def __init__( clock: Callable[[], float] | None = None, seller_authorization: Callable[[GrantRequest], Awaitable[bool]] | None = None, legacy_authorization: Callable[[GrantRequest], Awaitable[bool]] | None = None, + resolve_house: HouseResolver | 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 + self._resolve_house = resolve_house 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, @@ -854,14 +1029,12 @@ async def _authorization( 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") + except TrustRecordUnavailableError: + return _unavailable("trust_record_changed_during_evaluation") + except TrustRecordError: + return _unlisted("signer_not_listed") + if any(record.expires_at <= self._clock() for record in records): + return _unavailable("trust_record_unavailable") if grant_expiry <= self._clock(): return GrantAuthorization(False, "grant_absent") return GrantAuthorization( @@ -871,19 +1044,31 @@ async def _authorization( 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] - ) + min([self._outage_deadline(record) for record in records] + [grant_expiry]) if authorized and records else None ), stale_on_outage=any(record.stale_on_outage for record in records), ) + def _outage_deadline(self, record: EvaluatedTrustRecord) -> float: + if record.stale_on_outage: + return record.expires_at + return record.expires_at + self.records.outage_continuation_seconds + + async def _signer_listing( + self, origin: str, *, non_spend: bool + ) -> EvaluatedTrustRecord | GrantAuthorization: + listing = await self.records.check_listing(origin, role="adcp:buying") + if listing.status == "listed" or ( + listing.status == "unavailable" and listing.within_grace and non_spend + ): + assert listing.record is not None + return listing.record + if listing.status == "unlisted": + return _unlisted("signer_not_listed") + return _unavailable("signer_trust_record_unavailable") + async def check( self, *, @@ -896,69 +1081,86 @@ async def check( 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) + """Evaluate one acts-for claim. + + ``allow_stale_on_outage`` marks a claim for an operation that commits + no spend: only such a claim continues on the last records read while + a record on the chain cannot be read. + """ + if signing_profile not in {"web-bot-auth", "adcp/request-signing/v1"}: + raise ValueError("unsupported authenticated signing profile") + non_spend = allow_stale_on_outage + try: + origin = canonical_origin(agent_origin) + except ValueError: + return _unlisted("grant_absent") + signer_operator = _registrable(origin) + brand_operator = _registrable(brand_domain) + operator = _registrable(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) + return _unlisted("grant_absent") 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") + # WBA authority always requires a current operator role entry. + listing = await self._signer_listing(origin, non_spend=non_spend) + if isinstance(listing, GrantAuthorization): + return listing + signer_record = listing if signer_operator == brand_operator == operator: return await self._authorization(True, [signer_record], agent_origin=origin) + house = brand_domain + if self._resolve_house is not None: + try: + resolved = await self._resolve_house(brand_domain, brand_id) + except Exception: + return _unavailable("brand_house_unavailable") + if isinstance(resolved, BrandHouse): + house = resolved.domain + if resolved.brand_id is not None: + # Grants name the house's child-brand ids. + brand_id = resolved.brand_id + elif isinstance(resolved, str): + house = resolved + if _registrable(house) is None: + return _unlisted("grant_absent") try: - brand_record = await self.records.evaluate( - brand_domain, allow_stale_on_outage=allow_stale_on_outage - ) + brand_record = await self.records.evaluate(house, allow_stale_on_outage=non_spend) 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 and signer_record.expires_at <= self._clock(): + # The callback cannot outlive the required operator listing. + return _unavailable("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, + legacy_unpublished_domain=house, ) return await self._authorization( authorized, [], legacy_fallback_allowed=True, - legacy_unpublished_domain=brand_domain, + legacy_unpublished_domain=house, ) 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") + except TrustRecordError: + # Including a 404/410 after the house published: an outage, never + # grant_absent and never a fallback to authorized_operators. + return _unavailable("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, + # the house has no trust record. Once the house 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") + listing = await self._signer_listing(origin, non_spend=non_spend) + if isinstance(listing, GrantAuthorization): + return listing + signer_record = listing if signer_operator == brand_operator == operator: return await self._authorization(True, [signer_record], agent_origin=origin) args: dict[str, Any] = { @@ -968,84 +1170,77 @@ async def check( "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) - ] + grants = brand_record.document.get("grants", []) 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 operator in {signer_operator, brand_operator}: + # The house grants the signer's operator, which is account.operator; + # or a self-operated brand (it is account.operator) whose house + # grants the signer's operator directly. + links = [[g for g in grants if _grant_matches(g, grantee=signer_operator, **args)]] + else: + first = [g for g in grants if _grant_matches(g, grantee=operator, **args)] + if first: + try: + intermediary: EvaluatedTrustRecord | None = await self.records.evaluate( + operator, allow_stale_on_outage=non_spend + ) + except TrustRecordAbsentError: + # The intermediary publishes no record: no second link. + intermediary = None + except TrustRecordError: + return _unavailable("trust_record_unavailable") + second: list[Mapping[str, Any]] = [] + if intermediary is not None: + used_records.append(intermediary) + # Fetching the intermediary may outlive an earlier grant. + # Evaluate both links at the decision time. + 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(record.expires_at <= self._clock() for record in used_records): + return _unavailable("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", []) + _scope_covers(grant["scopes"], scope) for grant in 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") + # Seller discretion cannot outlive the records it relied on. + if any(record.expires_at <= self._clock() for record in used_records): + return _unavailable("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. + # Every surviving grant covers the requested dimensions. The best path + # expires at the minimum over links of each link's latest valid_until. + # 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 - ] + max(_instant(g["valid_until"]) 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") + if any(record.expires_at <= self._clock() for record in used_records): + return _unavailable("trust_record_unavailable") return await self._authorization( True, used_records, grant_expiry=grant_expiry, agent_origin=origin ) __all__ = [ + "BrandHouse", "GrantRequest", "EvaluatedTrustRecord", "GrantAuthorization", + "HouseResolver", "TrustFetcher", "TrustGrantEvaluator", + "TrustListing", "TrustRecordAbsentError", "TrustRecordError", "TrustRecordMatchError", diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 135b247e1..84ac75f53 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -124,6 +124,11 @@ class VerifiedSigner: operator_brand_json: dict[str, Any] | None = field(default=None, compare=False, repr=False) profile: str = DEFAULT_TAG principal_id: str | None = None + #: Web Bot Auth only: the key's state in its origin's revocation list, + #: set by the authorization layer after verification (``"current"``, + #: ``"revoked"``, ``"no_list"``, ``"stale"`` or ``"unavailable"``). + #: ``None`` means no revocation check ran, which never permits binding. + revocation: str | None = field(default=None, compare=False) class RequestBodyMalformedError(SignatureVerificationError): @@ -476,9 +481,11 @@ def verify_request_signature( body_authenticated = True if options.replay_store is not None: + # One extra second keeps the nonce through the last second of the + # accepted window, including a replay at exactly expires + skew. ttl = max( - float(parsed.params["expires"]) - options.now + options.max_skew_seconds, - 0.0, + float(parsed.params["expires"]) - options.now + options.max_skew_seconds + 1, + 1.0, ) claim = _claim_replay_nonce(options.replay_store, keyid, nonce, ttl) if claim == "replayed": diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index a8e7abd53..3d8535822 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1,24 +1,36 @@ """Experimental AdCP 3.3 Web Bot Auth transport profile. -Key discovery establishes an origin identity, never commercial authority. +Verification establishes an origin identity, never commercial authority. It +needs only the signer's key directory and a replay store +(:func:`verify_wba_request`), so it can run in front of the AdCP server. +Revocation (:func:`check_wba_revocation`), bindings and grants are the +authorization layer that runs after it. Nothing on the signature path reads a +trust record or a revocation list, and the signature path answers only with +Web Bot Auth statuses and headers (DR-0023): 400, 403 with or without +``Accept-Signature``, and 429. + Governance JWS verification continues to use the RFC 9421 discovery chain. """ from __future__ import annotations import asyncio +import base64 +import binascii import json import secrets import time from collections import OrderedDict -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Callable, Collection, Mapping, Sequence +from dataclasses import dataclass, replace from datetime import datetime, timezone -from typing import Any, Protocol +from typing import Any, Literal, Protocol, cast from urllib.parse import urlsplit import httpx -from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa from http_sfv.dictionary import Dictionary from http_sfv.item import InnerList, Item @@ -26,14 +38,9 @@ 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.canonical import _lookup, canonical_origin from adcp.signing.crypto import ( - PrivateKey, + b64url_decode, b64url_encode, public_key_from_jwk, sign_signature_base, @@ -50,67 +57,196 @@ DIRECTORY_TAG = "http-message-signatures-directory" DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json" +ALG_RSA_PSS_SHA512 = "rsa-pss-sha512" + +#: The signer's nonce is 64 random bytes (RECOMMENDED by the architecture +#: draft §4.2.2, and required by Cloudflare's ``web-bot-auth`` verifier). The +#: verifier accepts any 22-256 character nonce (see :func:`valid_wba_nonce`). +WBA_NONCE_BYTES = 64 +#: Seconds :func:`sign_key_directory` backdates ``created`` by, so a +#: verifier whose clock is slightly behind still accepts a fresh directory. +DIRECTORY_SKEW_ALLOWANCE_SECONDS = 300 +#: How long a cached directory keeps verifying while its refreshes fail +#: (WBA §6.10), counted from the last successful fetch. +DEFAULT_DIRECTORY_MAX_STALE_SECONDS = 86400 + +_DIRECTORY_MEDIA_TYPES = frozenset( + {DIRECTORY_MEDIA_TYPE, "application/jwk-set+json", "application/json"} +) +_DIRECTORY_MAX_BYTES = 256 * 1024 +_DIRECTORY_MAX_KEYS = 100 +_DIRECTORY_CACHE_SECONDS = 300 +_DISCOVERY_NEGATIVE_SECONDS = 60 +_DIRECTORY_CREATED_SKEW_SECONDS = 60 +_REQUEST_SKEW_SECONDS = 60 +_RSA_MIN_BITS, _RSA_MAX_BITS = 2048, 8192 +_PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k", "oth"}) + +#: The one body every signature-path failure carries, whatever the cause, so +#: the response is no oracle. The specific reason is for server logs only. +GENERIC_AUTHENTICATION_ERROR: dict[str, Any] = { + "errors": [ + { + "code": "AUTH_INVALID", + "message": "Request authentication failed", + "recovery": "correctable", + } + ] +} + +WbaRevocationStatus = Literal["current", "revoked", "no_list", "stale", "unavailable"] +_REVOCATION_STATUSES: frozenset[str] = frozenset( + {"current", "revoked", "no_list", "stale", "unavailable"} +) + + +def _new_nonce() -> str: + return b64url_encode(secrets.token_bytes(WBA_NONCE_BYTES)) + + +def is_64_byte_nonce(nonce: object) -> bool: + """Whether ``nonce`` encodes exactly 64 bytes, canonically. + + The architecture draft (§4.2.2) RECOMMENDS 64 random bytes, as unpadded + base64url or padded standard base64, and Cloudflare's ``web-bot-auth`` + verifier requires it. This SDK's signer always sends one. + """ + if type(nonce) is not str or not nonce.isascii(): + return False + candidates: list[tuple[bytes, str]] = [] + try: + raw = base64.urlsafe_b64decode(nonce + "=" * (-len(nonce) % 4)) + candidates.append((raw, b64url_encode(raw))) + except (binascii.Error, ValueError): + # Not base64url; the standard-base64 form is tried next. + pass + try: + raw = base64.b64decode(nonce, validate=True) + candidates.append((raw, base64.b64encode(raw).decode("ascii"))) + except (binascii.Error, ValueError): + # Not standard base64 either; only a decoded candidate can match. + pass + return any(len(raw) == WBA_NONCE_BYTES and encoded == nonce for raw, encoded in candidates) + + +def valid_wba_nonce(nonce: object) -> bool: + """Whether a received nonce is acceptable: 22 to 256 printable characters. + + 64 bytes is RECOMMENDED for signers (architecture draft §4.2.2), and + httpsig-02 adds no nonce requirement, so a compliant signer may send a + shorter nonce. The 22-character floor keeps at least 128 bits of entropy + in a base64 encoding. + """ + return ( + type(nonce) is str + and 22 <= len(nonce) <= 256 + and all(0x21 <= ord(c) <= 0x7E for c in nonce) + ) class WbaVerificationError(ValueError): - """A WBA failure, expressed as HTTP status and response headers.""" + """A Web Bot Auth signature-path failure: an HTTP status and headers. + + ``status`` is 400 (a field does not parse), 403 (a signature, profile + check, or key discovery fails; with ``Accept-Signature`` unless the key is + revoked) or 429 (capacity). Answer it with + :data:`GENERIC_AUTHENTICATION_ERROR`; ``reason`` is for logs only and must + never be sent to the caller. + """ + + discovery_failure: bool = False + revoked: bool = False - def __init__(self, reason: str, *, status: int = 403, label: str = "sig1") -> None: + def __init__( + self, + reason: str, + *, + status: int = 403, + label: str = "sig1", + challenge: bool = True, + ) -> None: super().__init__(reason) self.reason = reason self.status = status + self.label = label 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})) + if status == 403 and challenge: + self.headers["Accept-Signature"] = accept_signature_value(label) -class WbaAuthorizationError(WbaVerificationError): - """A key-policy rejection uses AdCP authentication semantics without a re-sign challenge. +def accept_signature_value(label: str = "sig1") -> str: + """``Accept-Signature`` naming what the profile requires (RFC 9421 §5.1). - A revoked key establishes no identity. Principal and grant authorization - after establishment uses the normal AdCP transport error binding. + Parameters indicate requirements, not values to sign; the nonce is a + fresh 64-byte value the signer may use on its next attempt. """ + 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": _new_nonce(), + "tag": WBA_TAG, + } + ) + return str(Dictionary({label: member})) - def __init__(self, reason: str) -> None: - super().__init__(reason) - self.headers = {} +class WbaDiscoveryError(WbaVerificationError): + """The signer's key directory could not be resolved (WBA §6.10). + + A network or TLS error, a timeout, any status other than 200 (404, 410, + 429 and 5xx included), or a response that is not a valid directory, with + no usable cached copy holding the key. The request is unverified, which + says nothing about the signer: by default it is answered 403 with + ``Accept-Signature``; a verifier may instead serve it as anonymous. + """ + + discovery_failure = True + + def __init__(self, reason: str, *, label: str = "sig1") -> None: + super().__init__(reason, status=403, label=label) + + +class WbaRevokedKeyError(WbaVerificationError): + """The signer's origin revoked the key: 403 without ``Accept-Signature``. + + The signer must stop using the key, so a hint to sign again would be + wrong. The body is the generic authentication error. + """ -class WbaUnavailableError(WbaVerificationError): - """Identity cannot be checked during an outage; retry without serving the request.""" + revoked = True - def __init__(self, reason: str) -> None: - super().__init__(reason, status=503) + def __init__(self, reason: str = "signing key revoked", *, label: str = "sig1") -> None: + super().__init__(reason, status=403, label=label, challenge=False) -def _dictionary(value: str | None) -> Dictionary: - if value is None or len(value) > 65536: - raise WbaVerificationError("missing or oversized signature field", status=400) +#: Deprecated alias of :class:`WbaRevokedKeyError`. +WbaAuthorizationError = WbaRevokedKeyError + + +class WbaUnavailableError(RuntimeError): + """An infrastructure outage (replay or revocation store), not a signature outcome. + + The server answers it at the application layer with the retryable + ``SERVICE_UNAVAILABLE`` AdCP error, never with a Web Bot Auth status. + """ + + +def _dictionary(value: str | None, *, missing_status: int = 400) -> Dictionary: + if value is None: + raise WbaVerificationError("missing signature field", status=missing_status) + if len(value) > 65536: + raise WbaVerificationError("oversized signature field", status=400) try: reject_dictionary_duplicates(value) result = Dictionary() @@ -160,13 +296,23 @@ def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: 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.""" +def _int_member(value: int) -> str: + return b64url_encode(value.to_bytes((value.bit_length() + 7) // 8, "big")) + + +WbaPrivateKey = ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey + + +def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: + """Export a WBA public JWK, with a thumbprint kid and no purpose claim. + + Ed25519, P-256 and RSA (2048 to 8192 bits, used with ``rsa-pss-sha512``) + keys are supported. + """ public = private_key.public_key() + jwk: dict[str, Any] if isinstance(public, ed25519.Ed25519PublicKey): - from cryptography.hazmat.primitives import serialization - - jwk: dict[str, Any] = { + jwk = { "kty": "OKP", "crv": "Ed25519", "x": b64url_encode( @@ -181,18 +327,61 @@ def public_directory_key(private_key: PrivateKey) -> dict[str, Any]: "x": b64url_encode(numbers.x.to_bytes(32, "big")), "y": b64url_encode(numbers.y.to_bytes(32, "big")), } + elif isinstance(public, rsa.RSAPublicKey): + rsa_numbers = public.public_numbers() + if not _RSA_MIN_BITS <= public.key_size <= _RSA_MAX_BITS: + raise ValueError("Web Bot Auth RSA keys must be 2048 to 8192 bits") + jwk = {"kty": "RSA", "n": _int_member(rsa_numbers.n), "e": _int_member(rsa_numbers.e)} else: - raise ValueError("Web Bot Auth supports Ed25519 and P-256 keys") + raise ValueError("Web Bot Auth supports Ed25519, P-256 and RSA keys") jwk.update({"kid": thumbprint_key_id(jwk), "use": "sig"}) return jwk +def _canonical_b64url(value: object) -> bool: + if type(value) is not str or not value or not value.isascii(): + return False + try: + return b64url_encode(b64url_decode(value)) == value + except (binascii.Error, ValueError): + return False + + +def _rsa_public_key(jwk: Mapping[str, Any]) -> rsa.RSAPublicKey: + modulus = b64url_decode(str(jwk.get("n", ""))) + exponent = b64url_decode(str(jwk.get("e", ""))) + if not modulus or modulus[0] == 0 or not exponent or len(exponent) > 4 or exponent[0] == 0: + raise ValueError("invalid RSA key members") + n = int.from_bytes(modulus, "big") + e = int.from_bytes(exponent, "big") + if not _RSA_MIN_BITS <= n.bit_length() <= _RSA_MAX_BITS: + raise ValueError("RSA modulus size not allowed") + if e < 3 or e > 2**31 - 1 or e % 2 == 0: + raise ValueError("RSA exponent not allowed") + return rsa.RSAPublicNumbers(e, n).public_key() + + def _algorithm(jwk: Mapping[str, Any]) -> str: + """Derive the signature algorithm and check the key may verify.""" 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: + alg = { + ("OKP", "Ed25519"): "ed25519", + ("EC", "P-256"): "ecdsa-p256-sha256", + ("RSA", "None"): ALG_RSA_PSS_SHA512, + }.get(shape) + if alg is None or jwk.get("use", "sig") != "sig" or _PRIVATE_JWK_MEMBERS & set(jwk): raise WbaVerificationError("unsupported directory key") - if jwk.get("alg") not in {None, "EdDSA" if alg == "ed25519" else "ES256"}: + # One key, one thumbprint: key members must be canonical base64url. + for member in ("x", "y", "n", "e"): + if member in jwk and not _canonical_b64url(jwk[member]): + raise WbaVerificationError("directory key member is not canonical base64url") + if alg == ALG_RSA_PSS_SHA512: + try: + _rsa_public_key(jwk) + except (ValueError, TypeError) as exc: + raise WbaVerificationError("unsupported RSA directory key") from exc + jws_alg = {"ed25519": "EdDSA", "ecdsa-p256-sha256": "ES256", ALG_RSA_PSS_SHA512: "PS512"}[alg] + if jwk.get("alg") not in {None, jws_alg}: 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"] @@ -201,6 +390,68 @@ def _algorithm(jwk: Mapping[str, Any]) -> str: return alg +def _sign(alg: str, private_key: WbaPrivateKey, base: bytes) -> bytes: + if alg == ALG_RSA_PSS_SHA512: + if not isinstance(private_key, rsa.RSAPrivateKey): + raise ValueError("alg/private_key mismatch: expected an RSA key") + return private_key.sign( + base, + padding.PSS(mgf=padding.MGF1(hashes.SHA512()), salt_length=64), + hashes.SHA512(), + ) + if isinstance(private_key, rsa.RSAPrivateKey): + raise ValueError("alg/private_key mismatch: RSA keys sign with rsa-pss-sha512") + return sign_signature_base(alg=alg, private_key=private_key, signature_base=base) + + +def _verify(alg: str, jwk: Mapping[str, Any], base: bytes, signature: bytes) -> bool: + if alg == ALG_RSA_PSS_SHA512: + try: + _rsa_public_key(jwk).verify( + signature, + base, + padding.PSS(mgf=padding.MGF1(hashes.SHA512()), salt_length=64), + hashes.SHA512(), + ) + return True + except InvalidSignature: + return False + return verify_signature( + alg=alg, + public_key=public_key_from_jwk(dict(jwk)), + signature_base=base, + signature=signature, + ) + + +def _received_authority(url: str) -> str: + """``@authority`` per RFC 9421 §2.2.3: the target URI's authority, normalized. + + Lowercase, with userinfo and a default port removed (HTTP §4.2.3). This + is not the AdCP origin canonicalization, which identifies the signer only. + """ + parts = urlsplit(url) + scheme = parts.scheme.lower() + authority = parts.netloc.rsplit("@", 1)[-1].lower() + host, sep, port = authority.rpartition(":") + default_port = {"https": "443", "http": "80"}.get(scheme) + if sep and "]" not in port and port in {"", default_port}: + authority = host + if not authority: + raise WbaVerificationError("request URL has no authority") + return authority + + +def _target_uri(url: str) -> str: + """``@target-uri`` per RFC 9421 §2.2.2: the target URI as received, unchanged.""" + if any(ord(c) <= 0x20 or ord(c) == 0x7F for c in url) or not url.isascii(): + raise WbaVerificationError("request URL is not a valid target URI", status=400) + parts = urlsplit(url) + if parts.scheme.lower() not in {"http", "https"} or not parts.netloc or parts.fragment: + raise WbaVerificationError("request URL is not an absolute target URI") + return url + + def _base( method: str, url: str, @@ -209,6 +460,12 @@ def _base( *, directory_response: bool = False, ) -> bytes: + """Build the RFC 9421 §2.5 signature base for ``member``. + + ``url`` is the target URI as sent or received. ``@target-uri`` is that + string unchanged; ``@authority`` is its normalized authority. The + canonical origin applies only to the ``Signature-Agent`` identity. + """ lines: list[str] = [] for component in member: name = component.value @@ -222,15 +479,19 @@ def _base( if name == "@method": value = method.upper() elif name == "@target-uri": - value = canonicalize_target_uri(url) + value = _target_uri(url) elif name == "@authority": - value = canonicalize_authority(url) + value = _received_authority(url) else: raise WbaVerificationError("unsupported derived component") else: + if name != name.lower(): + raise WbaVerificationError("covered field names must be lowercase") raw = _lookup(headers, name) if raw is None: raise WbaVerificationError("missing covered field") + if any(c in raw for c in "\r\n\x00"): + raise WbaVerificationError("covered field carries a control character", status=400) if params: if set(params) != {"key"} or type(params["key"]) is not str: raise WbaVerificationError("unsupported field component parameters") @@ -239,41 +500,42 @@ def _base( raise WbaVerificationError("missing covered dictionary member") value = str(dictionary[params["key"]]) else: - value = raw.strip() + value = raw.strip(" \t") 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: +def _window(member: InnerList, *, now: float, directory: bool = False, label: str = "sig1") -> None: params = member.params if type(params.get("tag")) is not str: - raise WbaVerificationError("signature tag must be an sf-string") + raise WbaVerificationError("signature tag must be an sf-string", label=label) for name in ("created", "expires"): if type(params.get(name)) is not int: - raise WbaVerificationError("missing integer time parameter") + raise WbaVerificationError("missing integer time parameter", label=label) 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") + raise WbaVerificationError("invalid signature lifetime", label=label) + if directory: + # Directory signatures tolerate a publisher clock slightly ahead, but + # an expired directory signature never verifies. + if created > now + _DIRECTORY_CREATED_SKEW_SECONDS or expires <= now: + raise WbaVerificationError("signature outside its validity window", label=label) + elif created > now + _REQUEST_SKEW_SECONDS or expires < now - _REQUEST_SKEW_SECONDS: + raise WbaVerificationError("signature outside its validity window", label=label) if type(params.get("keyid")) is not str or len(params["keyid"]) != 43: - raise WbaVerificationError("missing thumbprint keyid") + raise WbaVerificationError("missing thumbprint keyid", label=label) 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: + declared = member.params.get("alg", alg) + if type(declared) is not str or declared != alg: 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, - ) + valid = _verify(alg, jwk, base, signature.value) except (ValueError, KeyError, TypeError) as exc: raise WbaVerificationError("invalid directory key") from exc if not valid: @@ -287,14 +549,21 @@ def sign_wba_request( url: str, headers: Mapping[str, str], body: bytes, - private_key: PrivateKey, + private_key: WbaPrivateKey, 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.""" + """Sign with the opt-in WBA profile; infer alg and kid from the public key. + + ``url`` is the target URI exactly as the request will send it: + ``@target-uri`` covers it unchanged (RFC 9421 §2.2.2), percent-encoding + included. The nonce defaults to 64 random bytes. A request that already + carries signature fields is refused rather than stripped: another + party's signature is never silently dropped. + """ origin = _https_origin(agent_origin) if not 0 < expires_in_seconds <= 300: raise ValueError("expires_in_seconds must be in (0, 300]") @@ -302,14 +571,18 @@ def sign_wba_request( _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") + raise ValueError( + "request already carries signature fields; refusing to replace another " + "party's signature" + ) from adcp.signing.signer import _validate_sf_key _validate_sf_key(label, field="label") jwk = public_directory_key(private_key) + alg = _algorithm(jwk) 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: + nonce = _new_nonce() if nonce is None else nonce + if not valid_wba_nonce(nonce): raise ValueError( "nonce must carry at least 128 bits of entropy and be at most 256 characters" ) @@ -323,21 +596,21 @@ def sign_wba_request( agent = Item("signature-agent") agent.params["key"] = label components.append(agent) - member = InnerList([c for c in components]) + member = InnerList(list(components)) member.params.update( { "created": created, "expires": created + expires_in_seconds, "nonce": nonce, "keyid": jwk["kid"], - "alg": _algorithm(jwk), + "alg": alg, "tag": WBA_TAG, } ) - signature = sign_signature_base( - alg=_algorithm(jwk), - private_key=private_key, - signature_base=_base( + signature = _sign( + alg, + private_key, + _base( method, url, { @@ -354,82 +627,142 @@ def sign_wba_request( @dataclass(frozen=True) class DirectoryResponse: - """Signed directory bytes and headers, retained as evidence of discovery.""" + """Directory bytes and headers, retained as the record 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") +@dataclass(frozen=True) +class _VerifiedDirectory: + keys: dict[str, dict[str, Any]] + #: Earliest signature ``expires`` for a signed directory; ``None`` if unsigned. + signatures_expire: float | None + + +def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: try: - document = parse_strict_json(response.body) + document = parse_strict_json(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: + if not isinstance(keys, list) or not 1 <= len(keys) <= _DIRECTORY_MAX_KEYS: 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 + if not isinstance(jwk, dict): + raise WbaVerificationError("directory entry is not an object") + if _PRIVATE_JWK_MEMBERS & {str(name).lower() for name in jwk}: + raise WbaVerificationError("directory entry carries private key material") + if "key_ops" in jwk and ( + not isinstance(jwk["key_ops"], list) + or any(not isinstance(op, str) for op in jwk["key_ops"]) ): - raise WbaVerificationError("invalid or duplicate directory thumbprint") - _algorithm(jwk) - index[jwk["kid"]] = jwk + raise WbaVerificationError("directory key_ops is not a string array") + try: + _algorithm(jwk) + thumbprint = thumbprint_key_id(jwk) + except (WbaVerificationError, ValueError): + # A plain JWK Set may hold keys for other uses: skip what the + # profile cannot verify with. + continue + if thumbprint in index: + raise WbaVerificationError("directory repeats a key") + # A key is found by its thumbprint; a kid, when present, does not + # affect lookup (draft-02 §5.5: kid MAY be present). + index[thumbprint] = {**jwk, "kid": thumbprint} + if not index: + raise WbaVerificationError("directory holds no usable key") + return index + + +def _verify_directory( + origin: str, response: DirectoryResponse, *, now: float, require_signed: bool +) -> _VerifiedDirectory: + origin = _https_origin(origin) + media_type = (_lookup(response.headers, "content-type") or "").split(";", 1)[0] + if ( + response.status != 200 + or media_type.strip().lower() not in _DIRECTORY_MEDIA_TYPES + or len(response.body) > _DIRECTORY_MAX_BYTES + ): + raise WbaVerificationError("invalid key directory response") + raw_inputs = _lookup(response.headers, "signature-input") + raw_signatures = _lookup(response.headers, "signature") + if raw_inputs is None and raw_signatures is None: + # Appendix B.1 signing is RECOMMENDED, not required. + if require_signed: + raise WbaVerificationError("key directory is not signed") + return _VerifiedDirectory(_parse_directory_keys(response.body), None) digest = _lookup(response.headers, "content-digest") + # The digest covers the exact bytes served, a UTF-8 BOM included. 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() + keys = _parse_directory_keys(response.body) + inputs = _dictionary(raw_inputs) + signatures = _dictionary(raw_signatures) + if set(signatures) != set(inputs): + raise WbaVerificationError("directory signature labels differ") + signed: dict[str, dict[str, Any]] = {} + earliest = float("inf") 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) + _window(member, now=now, directory=True, label=label) covered = {str(c) for c in member} + # Exact identifiers: a "content-digest" member or a bare @authority + # does not bind the response's digest and request authority. 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: + if keyid not in keys or keyid in signed: raise WbaVerificationError("directory signature key is absent or repeated") _verify_crypto( member, signatures.get(label), - index[keyid], + keys[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 + signed[keyid] = keys[keyid] + earliest = min(earliest, float(member.params["expires"])) + # In a signed directory only keys carrying a verified possession proof + # are usable; an unsigned key alongside them is ignored. + return _VerifiedDirectory(signed, earliest) + + +def verify_directory_response( + origin: str, response: DirectoryResponse, *, now: float, require_signed: bool = False +) -> dict[str, dict[str, Any]]: + """Validate a key directory served at ``origin``; return keys by thumbprint. + + A plain, unsigned JWK Set is accepted unless ``require_signed``; every key + is indexed by its RFC 7638 thumbprint (a ``kid`` is optional). When the + response carries signatures (WBA Appendix B.1), every one must verify, + cover exactly ``"@authority";req`` and ``"content-digest"``, and only the + keys they prove are returned. + """ + return _verify_directory(origin, response, now=now, require_signed=require_signed).keys def sign_key_directory( *, agent_origin: str, - private_keys: Sequence[PrivateKey], + private_keys: Sequence[WbaPrivateKey], created: int | None = None, expires_in_seconds: int = 86400, ) -> DirectoryResponse: - """Generate a signed directory response; republish before its expires time.""" + """Generate a signed directory response; republish before its expires time. + + With no explicit ``created``, the signatures are backdated by + :data:`DIRECTORY_SKEW_ALLOWANCE_SECONDS` (verifiers allow little clock + skew on directory signatures) and still expire ``expires_in_seconds`` + from now. + """ origin = _https_origin(agent_origin) - if not 1 <= len(private_keys) <= 100 or expires_in_seconds <= 0: + if not 1 <= len(private_keys) <= _DIRECTORY_MAX_KEYS 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): @@ -440,30 +773,33 @@ def sign_key_directory( "Content-Digest": compute_content_digest_sha256(body), "Cache-Control": "max-age=300", } - created = int(time.time()) if created is None else created + if created is None: + now = int(time.time()) + created, expires = now - DIRECTORY_SKEW_ALLOWANCE_SECONDS, now + expires_in_seconds + else: + expires = created + expires_in_seconds 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")]) + alg = _algorithm(jwk) member.params.update( { "created": created, - "expires": created + expires_in_seconds, + "expires": expires, "keyid": jwk["kid"], - "alg": _algorithm(jwk), + "alg": alg, "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 - ), + _sign( + alg, + key, + _base("GET", origin + DIRECTORY_PATH, headers, member, directory_response=True), ) ) headers.update({"Signature-Input": str(inputs), "Signature": str(signatures)}) @@ -492,13 +828,21 @@ async def fetch() -> DirectoryResponse: trust_env=False, ) as client: async with client.stream( - "GET", url, headers={"Accept": DIRECTORY_MEDIA_TYPE, "Accept-Encoding": "identity"} + "GET", + url, + headers={ + "Accept": ( + f"{DIRECTORY_MEDIA_TYPE}, application/jwk-set+json;q=0.9, " + "application/json;q=0.8" + ), + "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) + await async_read_limited_bytes(response, limit=_DIRECTORY_MAX_BYTES) if response.status_code == 200 else b"" ), @@ -507,31 +851,44 @@ async def fetch() -> DirectoryResponse: 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 +class _DirectoryEntry: + response: DirectoryResponse keys: dict[str, dict[str, Any]] + refresh_at: float expires: float - retry_after: float = 0 - failure: _DirectoryFailure | None = None + + +def _operator_bucket(origin: str) -> str: + """Quota group for an origin: PSL PRIVATE tenants share their provider's group.""" + from adcp.signing.etld import quota_key + + return quota_key(origin) or urlsplit(origin).hostname or origin class DiscoveredSignerKeys: - """Discover many WBA signers using (covered origin, thumbprint), with bounded caching. + """Discover many WBA signers by (covered origin, thumbprint), with bounded caching. + + Caching follows WBA §6.10. A directory is refreshed after at most five + minutes (sooner if its signatures expire). A key absent from a directory + that resolves is absent: the new directory replaces the cached one. A + directory that fails to resolve (a network or TLS error, a timeout, any + status other than 200, or an invalid directory) is not evidence: it + never evicts the cached entry, which keeps verifying for up to + ``max_stale_seconds`` from the last successful fetch (never past the + directory signatures' expiry), and the failure is retried after a + 60-second cooldown. With no usable cached entry the lookup raises + :class:`WbaDiscoveryError`. A key missing from a stale cached copy is a + discovery failure too, since a key rotated in during the outage cannot be + seen. + + Successful directories are a bounded LRU cache of ``max_origins`` + origins; eviction only forces a refetch. Concurrent discoveries are + bounded globally and per operator (a registrable domain, with every + tenant of a PSL PRIVATE hosting suffix counted together). A discovery + the resolver's own limits refuse is local load shedding: a 429, not a + re-sign challenge, and a cached copy keeps verifying for one more cache + period only. A fetcher is a trusted test/transport seam and must enforce fetch policy. This resolver never reads capabilities, brand.json, or trust.json. @@ -544,6 +901,8 @@ def __init__( allow_private: bool = False, max_origins: int = 256, max_origins_per_operator: int = 8, + require_signed_directories: bool = False, + max_stale_seconds: float = DEFAULT_DIRECTORY_MAX_STALE_SECONDS, clock: Callable[[], float] = time.time, legacy_resolver: SignerKeyResolver | None = None, ) -> None: @@ -555,18 +914,41 @@ def __init__( ) ): raise ValueError("origin admission limits must be positive") + if not max_stale_seconds > 0: + raise ValueError("max_stale_seconds 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._require_signed = require_signed_directories + self._max_stale = float(max_stale_seconds) + self._cache: OrderedDict[str, _DirectoryEntry] = OrderedDict() + self._failures: OrderedDict[str, float] = OrderedDict() self._lock = asyncio.Lock() self._legacy_resolver = legacy_resolver - self._pending: dict[str, asyncio.Task[_DirectoryCache]] = {} + self._pending: dict[str, asyncio.Task[_DirectoryEntry]] = {} + + def evidence(self, origin: str) -> DirectoryResponse | None: + """The last directory response that resolved for ``origin``, if cached.""" + try: + entry = self._cache.get(_https_origin(origin)) + except ValueError: + return None + return entry.response if entry is not None else None + + @staticmethod + def _lookup_key( + origin: str, entry: _DirectoryEntry, keyid: str, *, stale: bool + ) -> ResolvedSignerKey | None: + key = entry.keys.get(keyid) + if key is not None: + return ResolvedSignerKey(origin, dict(key)) + if stale: + raise WbaDiscoveryError("key absent from a stale cached directory") + return None async def __call__( self, keyid: str, *, signer_origin: str | None = None @@ -574,88 +956,109 @@ async def __call__( 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) + try: + origin = _https_origin(signer_origin) + except ValueError as exc: + raise WbaVerificationError("invalid signer origin") from exc 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: + entry = self._cache.get(origin) + if entry is not None and now < entry.refresh_at: 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") + return self._lookup_key(origin, entry, keyid, stale=False) + stale = entry if entry is not None and now < entry.expires else None + if self._failures.get(origin, 0) > now: + if stale is not None: + return self._lookup_key(origin, stale, keyid, stale=True) + raise WbaDiscoveryError("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)) + refused = None + # Refreshing a cached origin does not compete with first + # discoveries for the global slots, so a flood of new origins + # cannot hold a known signer's refresh off. + if entry is None and len(self._pending) >= self._max_origins: + refused = "key directory discovery capacity exceeded" + else: + bucket = _operator_bucket(origin) + if ( + sum(_operator_bucket(o) == bucket for o in self._pending) + >= self._max_origins_per_operator + ): + refused = "operator key directory discovery capacity exceeded" + if refused is not None: + # A local refusal says nothing about the origin: a cached + # copy keeps verifying for one more cache period only, so + # verifier load cannot keep a removed key alive. + if entry is not None and now < min( + entry.expires, entry.refresh_at + _DIRECTORY_CACHE_SECONDS + ): + return self._lookup_key(origin, entry, keyid, stale=True) + raise WbaVerificationError(refused, status=429) + task = asyncio.create_task(self._refresh(origin)) # 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 + fresh = await asyncio.shield(task) + except asyncio.CancelledError: + raise 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) + entry = self._cache.get(origin) + stale = entry if entry is not None and self._clock() < entry.expires else None + if stale is not None: + return self._lookup_key(origin, stale, keyid, stale=True) + if isinstance(exc, WbaDiscoveryError): + raise WbaDiscoveryError(exc.reason) from exc + raise WbaDiscoveryError("key directory could not be resolved") from exc + return self._lookup_key(origin, fresh, keyid, stale=False) + + async def _refresh(self, origin: str) -> _DirectoryEntry: + try: + try: + # Calling the fetcher inside this guard also contains a + # fetcher that raises synchronously. + response = await self._fetcher( + origin + DIRECTORY_PATH, allow_private=self._allow_private ) - 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: + if not isinstance(response, DirectoryResponse) or response.status != 200: + # Any status other than 200 is a discovery failure (§5.5). + raise WbaVerificationError("key directory fetch did not return 200") + verified = _verify_directory( + origin, response, now=self._clock(), require_signed=self._require_signed + ) + except asyncio.CancelledError: raise - raise failure from exc + except Exception as exc: + async with self._lock: + now = self._clock() + # A failure never evicts the cached directory. + self._failures[origin] = now + _DISCOVERY_NEGATIVE_SECONDS + self._failures.move_to_end(origin) + for failed, until in list(self._failures.items()): + if until <= now: + del self._failures[failed] + while len(self._failures) > self._max_origins: + self._failures.popitem(last=False) + raise WbaDiscoveryError("key directory could not be resolved") from exc + now = self._clock() + expires = now + self._max_stale + if verified.signatures_expire is not None: + expires = min(expires, verified.signatures_expire) + entry = _DirectoryEntry( + response, verified.keys, min(now + _DIRECTORY_CACHE_SECONDS, expires), expires + ) + async with self._lock: + self._failures.pop(origin, None) + self._cache[origin] = entry + self._cache.move_to_end(origin) + while len(self._cache) > self._max_origins: + self._cache.popitem(last=False) + return entry finally: self._pending.pop(origin, None) @@ -668,15 +1071,37 @@ async def verify_wba_request( body: bytes, signer_keys: WbaSignerKeyResolver, replay_store: ReplayStore | None, - revocation_checker: Any, + revocation_checker: Any = None, now: float | None = None, raw_headers: Sequence[tuple[bytes, bytes]] | None = None, + replay_status: Literal[403, 429] = 429, + authorities: Collection[str] | None = None, ) -> tuple[VerifiedSigner, ...]: - """Verify all WBA signatures and relay coverage; return established origins only.""" + """Verify every WBA signature and relay coverage; return established origins. + + ``url`` is the target URI as received (RFC 9421 §2.2.2): build it from + the request line, not a decoded path. Verification needs only the key + directory (``signer_keys``) and the replay store, reads no trust record + and grants no authority. The first signer is the originating signer and + the AdCP caller; later ones are relays that covered it. + + Failures raise :class:`WbaVerificationError` (or its + :class:`WbaDiscoveryError` subclass for an unresolvable directory), whose + status and headers are the whole answer. A replay-store failure raises + :class:`WbaUnavailableError`, an application-layer outage. + + A replayed nonce is 429 by default; ``replay_status=403`` is a + compatibility switch for #7894's older replay vector. ``authorities`` + lists the ``host[:port]`` values this endpoint serves; a request whose + ``@authority`` is not among them is refused before key discovery. + + ``revocation_checker`` is optional and belongs to the authorization + layer. When given, :func:`check_wba_revocation` runs after verification: + a revoked key raises :class:`WbaRevokedKeyError`, and every signer + records its revocation state. + """ 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) @@ -684,9 +1109,14 @@ async def verify_wba_request( 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")) + # A missing signature is a 403 challenge; a present field that does not + # parse is a 400. + inputs = _dictionary(_lookup(headers, "signature-input"), missing_status=403) + signatures = _dictionary(_lookup(headers, "signature"), missing_status=403) + raw_agent = _lookup(headers, "signature-agent") + # Selection by tag alone leaves every Signature-Agent member absent, which + # fails each signature with 403 below. + agents = _dictionary(raw_agent) if raw_agent is not None else Dictionary() wba = [ (label, member) for label, member in inputs.items() @@ -696,13 +1126,22 @@ async def verify_wba_request( raise WbaVerificationError("Signature-Agent requires the Web Bot Auth profile") if set(signatures) != set(inputs): raise WbaVerificationError("signature labels do not match", status=400) + if authorities is not None: + # A signature captured at another seller cannot be replayed to this + # endpoint under that seller's Host. + allowed = {_received_authority("https://" + a) for a in authorities} + if _received_authority(url) not in allowed: + raise WbaVerificationError( + "request authority is not served by this verifier", label=wba[0][0] + ) verified: list[tuple[VerifiedSigner, str, float]] = [] earlier: list[str] = [] for label, member in wba: - _window(member, now=now) + _window(member, now=now, label=label) nonce = member.params.get("nonce") - if type(nonce) is not str or not 22 <= len(nonce) <= 256: + if not valid_wba_nonce(nonce): raise WbaVerificationError("missing or invalid nonce", label=label) + nonce = cast(str, nonce) agent = agents.get(label) if ( not isinstance(agent, Item) @@ -736,31 +1175,36 @@ async def verify_wba_request( keyid = member.params["keyid"] try: key = await signer_keys(keyid, signer_origin=origin) - except WbaVerificationError: + except WbaVerificationError as exc: + exc.label = label + if exc.status == 403 and "Accept-Signature" in exc.headers: + exc.headers["Accept-Signature"] = accept_signature_value(label) 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 - ): + # A resolver that cannot answer leaves the request unverified. + raise WbaDiscoveryError("signer key resolution failed", label=label) from exc + try: + matches = ( + key is not None + and canonical_origin(key.agent_url) == origin + and thumbprint_key_id(key.jwk) == keyid + ) + except (ValueError, TypeError, AttributeError): + matches = False + if not matches: raise WbaVerificationError("key absent from the named origin", label=label) - alg = _verify_crypto( - member, signatures.get(label), key.jwk, _base(method, url, headers, member) - ) + assert key is not None + try: + alg = _verify_crypto( + member, signatures.get(label), key.jwk, _base(method, url, headers, member) + ) + except WbaVerificationError as exc: + exc.label = label + raise 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( @@ -773,54 +1217,149 @@ async def verify_wba_request( profile=WBA_TAG, ), nonce, - member.params["expires"] - now + 60, + # One extra second keeps the nonce through the last second of + # the accepted window, including a replay at expires + 60. + max(member.params["expires"] - now + _REQUEST_SKEW_SECONDS + 1, 1), ) ) 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) - ) + # Claim relays before the originator, so a relay replay does not burn + # the originator's nonce. No nonce is claimed until every signature + # verified. + for signer, nonce, ttl in reversed(verified): + try: + claim = await asyncio.to_thread( + _claim_replay_nonce, replay_store, signer.agent_url or "", nonce, ttl + ) + except Exception as exc: + raise WbaUnavailableError("replay store unavailable") from exc + if claim == "replayed": + # A reused nonce is 429 (security.mdx, Web Bot Auth identities). + # replay_status=403 reproduces #7894's older negative/002 vector. + raise WbaVerificationError("replayed nonce", status=replay_status, label=signer.label) 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, + "replay store capacity exceeded", status=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 + signers = tuple(replace(signer, parsed_body=parsed) for signer, _, _ in verified) + if revocation_checker is not None: + signers = await check_wba_revocation(revocation_checker, signers) + return signers - return tuple(replace(signer, parsed_body=parsed) for signer, _, _ in verified) + +async def wba_revocation_status(checker: Any, origin: str, keyid: str) -> WbaRevocationStatus: + """A key's state in its origin's revocation list, for the authorization layer. + + ``checker`` is a :class:`WbaRevocationStatusChecker` (``status(origin, + keyid)``), or a legacy async callable ``(origin, keyid) -> bool`` whose + failure means the list cannot be read (``"stale"``). + """ + status_method = getattr(checker, "status", None) + if callable(status_method): + try: + status = await status_method(origin, keyid) + except Exception: + return "unavailable" + return cast(WbaRevocationStatus, status) if status in _REVOCATION_STATUSES else "stale" + try: + revoked = await checker(origin, keyid) + except Exception: + return "stale" + return "revoked" if revoked else "current" + + +async def check_wba_revocation( + checker: Any, signers: Sequence[VerifiedSigner] +) -> tuple[VerifiedSigner, ...]: + """Record each verified signer's revocation state; refuse a revoked key. + + Runs in the authorization layer, after verification. A revoked key, the + originator's or a relay's, raises :class:`WbaRevokedKeyError` (403, no + ``Accept-Signature``). Every other state is recorded in + :attr:`VerifiedSigner.revocation` for the binding and grant gates. + """ + checked: list[VerifiedSigner] = [] + for signer in signers: + status = await wba_revocation_status(checker, signer.agent_url or "", signer.key_id) + if status == "revoked": + raise WbaRevokedKeyError(label=signer.label) + checked.append(replace(signer, revocation=status)) + return tuple(checked) + + +class WbaRevocationStatusChecker(Protocol): + """Report a WBA key's state in its origin's signed revocation list.""" + + async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: + raise NotImplementedError("report the key's revocation state") __all__ = [ - "OriginRevocationChecker", - "WbaAuthorizationError", - "WbaUnavailableError", + "ALG_RSA_PSS_SHA512", + "DEFAULT_DIRECTORY_MAX_STALE_SECONDS", "DIRECTORY_MEDIA_TYPE", + "DIRECTORY_SKEW_ALLOWANCE_SECONDS", "DirectoryResponse", "DiscoveredSignerKeys", + "GENERIC_AUTHENTICATION_ERROR", + "OriginRevocationChecker", + "WBA_NONCE_BYTES", + "WbaAuthorizationError", + "WbaDiscoveryError", + "WbaRevocationStatus", + "WbaRevocationStatusChecker", + "WbaRevokedKeyError", + "WbaUnavailableError", "WbaVerificationError", + "accept_signature_value", + "check_wba_revocation", "public_directory_key", "sign_key_directory", "sign_wba_request", "thumbprint_key_id", "uses_web_bot_auth", + "is_64_byte_nonce", + "valid_wba_nonce", "verify_directory_response", "verify_wba_request", + "wba_revocation_status", ] -class OriginRevocationChecker: - """Verify origin revocation lists with keys from that origin's signed directory. +class _NoRevocationListError(Exception): + """The origin answered 404/410: it serves no revocation list.""" + - 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. +class OriginRevocationChecker: + """Check WBA keys against each origin's signed revocation list. + + The list lives at ``/.well-known/governance-revocations.json`` on the + origin (``adcp-gov-revocation+jws``), signed by a key from the origin's + directory. :meth:`status` reports, per key: + + * ``"revoked"``: the last verified list revokes it, however stale; + * ``"current"``: a list within ``next_update`` plus grace does not; + * ``"no_list"``: the origin answers 404/410 and no list was ever verified. + The identity is established but cannot be bound for spend; + * ``"stale"``: the list cannot be read or verified, is past grace, or + its revocations exceed this checker's fact limits. Spend-committing + calls fail closed in the authorization layer; other calls proceed; + * ``"unavailable"``: too many discoveries are in flight to check now. + Every bound call fails closed with ``SERVICE_UNAVAILABLE``. + + Revoked facts learned from authenticated lists are retained for the + checker's lifetime and never weakened or evicted; fact overflow refuses + the offending origin (it stays ``"stale"``) rather than evicting another + origin's revocations. Per-origin list state is a least-recently-used + cache of ``max_origins`` origins and ``max_origins_per_operator`` per + operator (a registrable domain; every tenant of a PSL PRIVATE hosting + suffix counts together), so a flood of origins cannot lock new origins + out. This state is in memory: revocations learned by one process are + lost on restart and are not shared across replicas. """ def __init__( @@ -859,52 +1398,106 @@ def __init__( 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._checkers: OrderedDict[str, Any] = OrderedDict() self._pending: dict[str, asyncio.Task[Any]] = {} - self._failures: OrderedDict[str, float] = OrderedDict() + # origin -> (retry_after, served no list) + self._failures: OrderedDict[str, tuple[float, bool]] = OrderedDict() self._lock = asyncio.Lock() async def __call__(self, origin: str, keyid: str) -> bool: - origin = _https_origin(origin) + """Compatibility check: True when revoked; raises when undecidable. + + Prefer :meth:`status`, which distinguishes an origin with no list + from one whose list is stale. + """ + status = await self.status(origin, keyid) + if status in {"revoked", "current"}: + return status == "revoked" + raise WbaUnavailableError(f"origin revocation status is {status}") + + def _retained(self, origin: str, keyid: str) -> bool: + return ("kid", keyid) in self._revoked_facts.get(origin, set()) + + async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: + """The key's revocation state; see the class docstring.""" + try: + origin = _https_origin(origin) + except ValueError: + return "stale" + if self._retained(origin, keyid): + return "revoked" async with self._lock: checker = self._checkers.get(origin) task = None - if checker is None: - if self._failures.get(origin, 0) > self._clock(): + if checker is not None: + self._checkers.move_to_end(origin) + else: + failure = self._failures.get(origin) + if failure is not None and failure[0] > self._clock(): self._failures.move_to_end(origin) - raise WbaUnavailableError("origin revocation discovery is cooling down") + return "no_list" if failure[1] else "stale" 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") + if not self._make_room(origin): + # Local backpressure (concurrent discoveries): no + # bound call may act until the list can be read. + return "unavailable" 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) + try: + checker = await asyncio.shield(task) + except asyncio.CancelledError: + raise + except _NoRevocationListError: + return "no_list" + except Exception: + return "revoked" if self._retained(origin, keyid) else "stale" assert checker is not None try: - return bool(await checker(keyid)) - except Exception as exc: - raise WbaUnavailableError("origin revocation status unavailable") from exc + return "revoked" if await checker(keyid) else "current" + except asyncio.CancelledError: + raise + except Exception: + # A stale list still revokes everything it revoked. + if checker.retains_revoked_key(keyid) or self._retained(origin, keyid): + return "revoked" + return "stale" + + def _make_room(self, origin: str) -> bool: + """Admit ``origin``, evicting least-recently-used checker state if needed. + + Called with the lock held. Checker state is a cache: evicting it never + forgets a revocation, because revoked facts are kept separately and + are checked first. Only concurrent discoveries can refuse admission, + so a flood of origins cannot lock new origins out. + """ + operator = self._operator(origin) + + def group(origins: Any) -> list[str]: + return [other for other in origins if self._operator(other) == operator] + + while len(group(self._checkers)) + len(group(self._pending)) >= ( + self._max_origins_per_operator + ): + evictable = group(self._checkers) + if not evictable: + return False + del self._checkers[evictable[0]] + while len(self._checkers) + len(self._pending) >= self._max_origins: + if not self._checkers: + return False + self._checkers.popitem(last=False) + return True @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 + return _operator_bucket(origin) def _admit_revocations(self, record: Any) -> bool: # Called synchronously after authentication and schema validation. @@ -928,7 +1521,10 @@ def _admit_revocations(self, record: Any) -> bool: return True async def _discover(self, origin: str) -> Any: - from adcp.signing.revocation_fetcher import AsyncCachingRevocationChecker + from adcp.signing.revocation_fetcher import ( + AsyncCachingRevocationChecker, + RevocationListFetchError, + ) checker = None try: @@ -954,16 +1550,23 @@ async def resolve(keyid: str) -> dict[str, Any] | None: self._failures.pop(origin, None) return checker except Exception as exc: + no_list = ( + isinstance(exc, RevocationListFetchError) + and exc.status_code in {404, 410} + and (checker is None or not checker.has_verified_list) + ) async with self._lock: if checker is not None and checker.has_verified_list: - # Freshness failure denies this request but cannot erase + # Freshness failure denies spend but cannot erase # revocations learned from the authenticated document. self._checkers[origin] = checker - self._failures[origin] = self._clock() + 60 + self._failures[origin] = (self._clock() + 60, no_list) 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 + if no_list: + raise _NoRevocationListError from exc + raise 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 index ddc6324a5..9afacbde1 100644 --- a/src/adcp/types/_identity_preview.py +++ b/src/adcp/types/_identity_preview.py @@ -19,7 +19,7 @@ class EnumsAgentOnboardingMode(str, Enum): """ - How a seller onboards buyer agents for one billing party or for sandbox access. Absent means undeclared, never invite_only. + How a party takes on counterparties it has not worked with. The direction comes from where the value appears: in a seller's account.agent_onboarding, how the seller onboards buyer agents for one billing party or for sandbox access; in a buyer's trust.json posture (core/buyer-onboarding-posture.json), how the buyer considers sellers. Absent means undeclared, never invite_only. """ open = "open" @@ -32,7 +32,6 @@ 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" @@ -55,7 +54,7 @@ class CoreExt(BaseModel): 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. + 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 aao_verified fields (role, verification_modes, verified_specialisms, adcp_version) use the AgenticAdvertising.org Verified badge token's own claim names (docs/building/verification/aao-verified.mdx). The requirement describes the gate the publishing party applies on its own side; it creates no conformance obligation on the counterparty. """ model_config = ConfigDict( @@ -72,9 +71,12 @@ class CoreAgentOnboardingRequirement(BaseModel): min_length=1, ), ] = None - specialisms: Annotated[ + verified_specialisms: Annotated[ list[str] | None, - Field(description="aao_verified: specialisms the badge must include.", min_length=1), + Field( + description="aao_verified: specialisms the badge's verified_specialisms claim must include.", + min_length=1, + ), ] = None adcp_version: Annotated[ str | None, Field(description="aao_verified: the AdCP release the badge must cover.") @@ -146,12 +148,6 @@ class CoreAgentOnboarding(BaseModel): 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( @@ -161,25 +157,15 @@ class CoreAgentOnboarding(BaseModel): 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). + How a buying agent considers sellers: a mode, the requirements a seller must meet, and where a seller's human starts. Published only in the buyer's trust.json: in the agent entry (agents[].profiles.adcp.onboarding), or as a record-level default (profiles.adcp.onboarding). Public and identical for every reader. mode uses the same enum as the seller's posture; here it says how the buyer considers sellers. See specs/agent-identity-3-3.md R7 (#8114). """ model_config = ConfigDict( extra="allow", ) - mode: EnumsBuyerOnboardingMode + mode: EnumsAgentOnboardingMode requirements: Annotated[ list[CoreAgentOnboardingRequirement] | None, Field( @@ -192,10 +178,6 @@ class CoreBuyerOnboardingPosture(BaseModel): 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( @@ -205,12 +187,12 @@ class CoreBuyerOnboardingPosture(BaseModel): ext: CoreExt | None = None -class Status(str, Enum): +class EnumsPrincipalRelationshipStatus(str, Enum): """ - Current state of the relationship. + State of the seller's commercial relationship with the authenticated caller, as get_principal reports it in relationship.status. """ - pending = "pending" + pending_approval = "pending_approval" active = "active" suspended = "suspended" blocked = "blocked" @@ -218,7 +200,7 @@ class Status(str, Enum): 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. + The caller's next step while status is pending_approval, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide. """ model_config = ConfigDict( @@ -236,13 +218,15 @@ class Setup(BaseModel): 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. + The seller's commercial relationship with the authenticated caller, returned by get_principal only to that caller. Sent only in responses served at 3.3 or later: a seller MUST omit it for callers below 3.3, whose get_principal result arms are closed (DR-0024, #8117). 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.")] + status: Annotated[ + EnumsPrincipalRelationshipStatus, Field(description="Current state of the relationship.") + ] expires_at: Annotated[ AwareDatetime | None, Field( @@ -252,7 +236,7 @@ class CorePrincipalRelationship(BaseModel): 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." + description="The caller's next step while status is pending_approval, in the same shape as sync_accounts setup. Specific to the authenticated caller, never seller-wide." ), ] = None ext: CoreExt | None = None @@ -988,7 +972,12 @@ class Adcp1(BaseModel): ) 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 + onboarding: Annotated[ + CoreBuyerOnboardingPosture | None, + Field( + description="For a buying agent (role adcp:buying): how it considers sellers. Overrides the record-level profiles.adcp.onboarding for this agent. trust.json is the only home for the buyer posture." + ), + ] = None class Brand(RootModel[str]): @@ -1006,7 +995,7 @@ class Adcp2(BaseModel): 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.", + description="Brand IDs from this organization's brand.json that the grant covers. ['*'] or omitted means every brand the grantor owns. On a grantee's own grant to an intermediary, the IDs are those of the brands in the grant it received.", min_length=1, ), ] = None @@ -1035,7 +1024,12 @@ class Adcp3(BaseModel): 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 + onboarding: Annotated[ + CoreBuyerOnboardingPosture | None, + Field( + description="Default buyer onboarding posture for every buying agent this record lists; an agent entry's profiles.adcp.onboarding overrides it." + ), + ] = None class Domain(RootModel[str]): @@ -1249,7 +1243,7 @@ class Agent(BaseModel): 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. + 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. In the AdCP profile this is the demand-side grant (#6033, DR-0025), evaluated as L2 §Grants in trust.json describes (docs/building/by-layer/L2/accounts-and-agents.mdx), at sync_accounts and on the freshness schedule. """ model_config = ConfigDict( @@ -1290,7 +1284,7 @@ class Grant(BaseModel): 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. + 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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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( @@ -1332,7 +1326,7 @@ class TrustV1Trust2(BaseModel): 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. + 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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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( @@ -1376,7 +1370,7 @@ class TrustV1Trust3(BaseModel): 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. + 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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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( @@ -1420,7 +1414,7 @@ class TrustV1Trust4(BaseModel): 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. + 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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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( @@ -1464,7 +1458,7 @@ class TrustV1Trust5(BaseModel): 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. + 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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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( @@ -1524,7 +1518,7 @@ class TrustV1Trust( | 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.", + 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 and on the freshness schedule (docs/building/by-layer/L2/accounts-and-agents.mdx, Grants in trust.json; 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", ), ] diff --git a/src/adcp/types/experimental_identity.py b/src/adcp/types/experimental_identity.py index b9df09398..79a582ef4 100644 --- a/src/adcp/types/experimental_identity.py +++ b/src/adcp/types/experimental_identity.py @@ -1,5 +1,12 @@ """Opt-in types for pinned AdCP 3.3 identity drafts, independent of the wire pin. +The schemas are pinned to open spec branches (see +adcp/signing/_identity_schemas/UPSTREAM.json). Seller and buyer onboarding +postures share one mode enum, AgentOnboardingMode (open, application, +invite_only). The buyer posture is published only in trust.json, at +profiles.adcp.onboarding on the record or on an agent entry. relationship on +get_principal is 3.3-only; its status is PrincipalRelationshipStatus. + 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. @@ -36,7 +43,7 @@ EnumsAgentOnboardingRequirementKind as AgentOnboardingRequirementKind, ) from adcp.types._identity_preview import ( - EnumsBuyerOnboardingMode as BuyerOnboardingMode, + EnumsPrincipalRelationshipStatus as PrincipalRelationshipStatus, ) from adcp.types._identity_preview import ( Grant as TrustGrant, @@ -134,7 +141,6 @@ class GrantAbsentDetails(BaseModel): "AgentOnboardingRequirement", "AgentOnboardingRequirementKind", "AgentOnboardingTier", - "BuyerOnboardingMode", "BuyerOnboardingPosture", "GetAdcpCapabilitiesResponse", "GetPrincipalResponse", @@ -143,6 +149,7 @@ class GrantAbsentDetails(BaseModel): "PrincipalCurrentResult", "PrincipalRecognizedResult", "PrincipalRelationship", + "PrincipalRelationshipStatus", "PrincipalUnconfiguredResult", "RequestSigning", "TrustAgent", diff --git a/tests/conformance/signing/test_jws_joserfc.py b/tests/conformance/signing/test_jws_joserfc.py new file mode 100644 index 000000000..27b5497ec --- /dev/null +++ b/tests/conformance/signing/test_jws_joserfc.py @@ -0,0 +1,578 @@ +"""Regression tests for the AdCP profile rules kept on top of ``joserfc``. + +``adcp.signing.jws`` hands JOSE parsing and signature verification to +``joserfc``. These tests check that each AdCP rule still holds: the strict +JSON pre-parse, the alg allow-list, ``typ`` / ``kid`` / ``crit``, strict +base64url, the Ed25519-only ``EdDSA`` pin, and the full-length P-256 +coordinate check. They also check that joserfc's defaults do not reject what +the profile accepts: unknown header parameters, large payloads, and the +deprecated ``EdDSA`` alg name. + +Tokens are signed with ``cryptography`` directly, not with joserfc, so the +library is never only checking its own output. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import warnings +from typing import Any + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519 + +from adcp.signing.crypto import ( + ALG_ED25519, + ALG_ES256, + b64url_encode, + sign_signature_base, +) +from adcp.signing.jws import ( + JwsMalformedError, + JwsSignatureInvalidError, + averify_detached_jws, + averify_jws_document, + parse_compact_jws, + parse_general_json_jws, + verify_detached_jws, + verify_jws_document, +) + +TYP = "adcp-gov-revocation+jws" +KID = "gov-key-1" +PAYLOAD: dict[str, Any] = { + "version": 1, + "issuer": "https://gov.example.com", + "updated": "2026-04-18T14:00:00Z", + "next_update": "2026-04-18T14:15:00Z", + "revoked_jtis": [], + "revoked_kids": ["gov-2026-03"], +} + + +# -- helpers ------------------------------------------------------------ + + +def _ed25519() -> tuple[dict[str, Any], ed25519.Ed25519PrivateKey]: + private_key = ed25519.Ed25519PrivateKey.generate() + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": ["verify"], + "kid": KID, + "x": b64url_encode(private_key.public_key().public_bytes_raw()), + } + return jwk, private_key + + +def _es256() -> tuple[dict[str, Any], ec.EllipticCurvePrivateKey]: + private_key = ec.generate_private_key(ec.SECP256R1()) + numbers = private_key.public_key().public_numbers() + jwk = { + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "use": "sig", + "key_ops": ["verify"], + "kid": KID, + "x": b64url_encode(numbers.x.to_bytes(32, "big")), + "y": b64url_encode(numbers.y.to_bytes(32, "big")), + } + return jwk, private_key + + +def _header(**overrides: Any) -> dict[str, Any]: + header: dict[str, Any] = {"alg": "EdDSA", "kid": KID, "typ": TYP} + header.update(overrides) + return {k: v for k, v in header.items() if v is not None} + + +def _b64_json(value: Any) -> str: + return b64url_encode(json.dumps(value, separators=(",", ":")).encode("utf-8")) + + +def _sign_raw(b64_header: str, b64_payload: str, private_key: Any, internal_alg: str) -> str: + signature = sign_signature_base( + alg=internal_alg, + private_key=private_key, + signature_base=(b64_header + "." + b64_payload).encode("ascii"), + ) + return f"{b64_header}.{b64_payload}.{b64url_encode(signature)}" + + +def _sign( + private_key: Any, + *, + header: dict[str, Any] | None = None, + payload: dict[str, Any] | None = None, + internal_alg: str = ALG_ED25519, +) -> str: + return _sign_raw( + _b64_json(header if header is not None else _header()), + _b64_json(payload if payload is not None else PAYLOAD), + private_key, + internal_alg, + ) + + +def _resolver(jwk: dict[str, Any]) -> Any: + def resolve(kid: str) -> dict[str, Any] | None: + return jwk if kid == jwk["kid"] else None + + return resolve + + +def _verify(token: str | dict[str, Any], jwk: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + return verify_jws_document(token, jwks_resolver=_resolver(jwk), expected_typ=TYP, **kwargs) + + +# -- strict JSON (parser-differential defense) --------------------------- + + +def test_duplicate_key_in_protected_header_rejected() -> None: + jwk, key = _ed25519() + raw_header = f'{{"alg":"EdDSA","kid":"{KID}","typ":"other+jws","typ":"{TYP}"}}' + token = _sign_raw(b64url_encode(raw_header.encode()), _b64_json(PAYLOAD), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="protected header is not valid JSON"): + _verify(token, jwk) + + +def test_duplicate_key_in_payload_rejected_even_when_signed() -> None: + # A validly signed list whose second "revoked_kids" erases the first: + # json.loads (what joserfc uses) would read the empty one. + jwk, key = _ed25519() + raw_payload = ( + '{"version":1,"issuer":"https://gov.example.com",' + '"revoked_kids":["gov-2026-03"],"revoked_kids":[]}' + ) + token = _sign_raw(_b64_json(_header()), b64url_encode(raw_payload.encode()), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="JWS payload is not valid JSON: duplicate_key"): + _verify(token, jwk) + + +def test_nested_duplicate_key_in_payload_rejected() -> None: + jwk, key = _ed25519() + raw_payload = '{"version":1,"meta":{"a":1,"a":2}}' + token = _sign_raw(_b64_json(_header()), b64url_encode(raw_payload.encode()), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="duplicate_key"): + _verify(token, jwk) + + +@pytest.mark.parametrize("raw_payload", [b'{"version":NaN}', b'{"version":1e999}']) +def test_non_finite_number_in_payload_rejected(raw_payload: bytes) -> None: + jwk, key = _ed25519() + token = _sign_raw(_b64_json(_header()), b64url_encode(raw_payload), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="not valid JSON"): + _verify(token, jwk) + + +def test_non_utf8_payload_rejected() -> None: + jwk, key = _ed25519() + token = _sign_raw( + _b64_json(_header()), b64url_encode('{"v":"é"}'.encode("utf-16")), key, ALG_ED25519 + ) + with pytest.raises(JwsMalformedError, match="not valid JSON: invalid_utf8"): + _verify(token, jwk) + + +def test_header_must_be_object() -> None: + jwk, key = _ed25519() + token = _sign_raw(_b64_json(["alg", "EdDSA"]), _b64_json(PAYLOAD), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="protected header is not a JSON object"): + _verify(token, jwk) + + +# -- alg allow-list ----------------------------------------------------- + + +def test_alg_none_with_empty_signature_rejected() -> None: + jwk, _ = _ed25519() + token = f"{_b64_json(_header(alg='none'))}.{_b64_json(PAYLOAD)}." + # Compact parse rejects the empty signature segment first. + with pytest.raises(JwsMalformedError): + _verify(token, jwk) + # The detached path reaches the alg check directly. + with pytest.raises(JwsMalformedError, match="alg 'none' not allowed"): + verify_detached_jws( + b64_protected=_b64_json(_header(alg="none")), + b64_payload=_b64_json(PAYLOAD), + signature=b"", + jwks_resolver=_resolver(jwk), + expected_typ=TYP, + ) + + +def test_hs256_key_confusion_rejected() -> None: + # Classic alg-confusion: HMAC the token with the public key bytes as secret. + jwk, _ = _ed25519() + b64_header = _b64_json(_header(alg="HS256")) + b64_payload = _b64_json(PAYLOAD) + mac = hmac.new( + jwk["x"].encode(), f"{b64_header}.{b64_payload}".encode(), hashlib.sha256 + ).digest() + token = f"{b64_header}.{b64_payload}.{b64url_encode(mac)}" + with pytest.raises(JwsMalformedError, match="alg 'HS256' not allowed"): + _verify(token, jwk) + + +@pytest.mark.parametrize("alg", ["Ed25519", "ES384", "RS256", "eddsa", ""]) +def test_algs_outside_profile_rejected(alg: str) -> None: + jwk, key = _ed25519() + token = _sign(key, header=_header(alg=alg)) + with pytest.raises(JwsMalformedError, match="not allowed"): + _verify(token, jwk) + + +def test_caller_allow_list_narrows_profile() -> None: + jwk, key = _ed25519() + token = _sign(key) + with pytest.raises(JwsMalformedError, match="not allowed"): + _verify(token, jwk, allowed_algs=frozenset({"ES256"})) + + +def test_caller_allow_list_cannot_widen_profile() -> None: + # Pre-joserfc this raised KeyError; a non-profile alg is now just refused. + jwk, key = _ed25519() + token = _sign(key, header=_header(alg="RS256")) + with pytest.raises(JwsMalformedError, match="not allowed"): + _verify(token, jwk, allowed_algs=frozenset({"RS256", "EdDSA"})) + + +def test_empty_allow_list_rejects_everything() -> None: + jwk, key = _ed25519() + with pytest.raises(JwsMalformedError, match="not allowed"): + _verify(_sign(key), jwk, allowed_algs=frozenset()) + + +def test_eddsa_verify_emits_no_deprecation_warning() -> None: + # joserfc warns that RFC 9864 deprecates "EdDSA"; AdCP mandates the name. + jwk, key = _ed25519() + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert _verify(_sign(key), jwk) == PAYLOAD + + +# -- typ / kid / crit --------------------------------------------------- + + +@pytest.mark.parametrize( + "typ", ["ADCP-GOV-REVOCATION+JWS", "adcp-gov-revocation", "adcp-gov+jws", " " + TYP, None] +) +def test_typ_must_match_byte_for_byte(typ: str | None) -> None: + jwk, key = _ed25519() + token = _sign(key, header=_header(typ=typ)) + with pytest.raises(JwsMalformedError, match="typ"): + _verify(token, jwk) + + +@pytest.mark.parametrize("kid", ["", 7, ["gov-key-1"]]) +def test_kid_must_be_non_empty_string(kid: Any) -> None: + jwk, key = _ed25519() + token = _sign(key, header=_header(kid=kid)) + with pytest.raises(JwsMalformedError, match="kid"): + _verify(token, jwk) + + +def test_header_rejected_before_key_resolution() -> None: + jwk, key = _ed25519() + calls: list[str] = [] + + def resolve(kid: str) -> dict[str, Any] | None: + calls.append(kid) + return jwk + + token = _sign(key, header=_header(typ="other+jws")) + with pytest.raises(JwsMalformedError): + verify_jws_document(token, jwks_resolver=resolve, expected_typ=TYP) + assert calls == [] + + +def test_empty_crit_accepted() -> None: + jwk, key = _ed25519() + assert _verify(_sign(key, header=_header(crit=[])), jwk) == PAYLOAD + + +@pytest.mark.parametrize("crit", [["b64"], "b64", {}]) +def test_non_empty_or_malformed_crit_rejected(crit: Any) -> None: + jwk, key = _ed25519() + token = _sign(key, header=_header(crit=crit, b64=True)) + with pytest.raises(JwsMalformedError, match="crit"): + _verify(token, jwk) + + +def test_rfc7797_b64_false_without_crit_rejected() -> None: + # joserfc honors RFC 7797; unencoded payloads need "b64" in crit, which + # the profile forbids, so b64=false can never verify. + jwk, key = _ed25519() + token = _sign(key, header=_header(b64=False)) + with pytest.raises(JwsMalformedError): + _verify(token, jwk) + + +def test_unknown_header_parameters_accepted() -> None: + # joserfc's strict header check would reject these; the profile ignores + # non-critical parameters, as the pre-joserfc verifier did. + jwk, key = _ed25519() + token = _sign(key, header=_header(iat=1776520800, x_adcp_note="ok")) + assert _verify(token, jwk) == PAYLOAD + + +def test_registered_header_parameter_with_wrong_type_rejected() -> None: + jwk, key = _ed25519() + token = _sign(key, header=_header(jku="not-a-url")) + with pytest.raises(JwsMalformedError): + _verify(token, jwk) + + +# -- strict base64url --------------------------------------------------- + + +def test_padded_signature_rejected_by_parsers() -> None: + _, key = _ed25519() + token = _sign(key) + b64_header, b64_payload, b64_signature = token.split(".") + padded = b64_signature + "=" * (-len(b64_signature) % 4) + assert padded != b64_signature + with pytest.raises(JwsMalformedError, match="signature is not valid base64url"): + parse_compact_jws(f"{b64_header}.{b64_payload}.{padded}") + with pytest.raises(JwsMalformedError, match="signature is not valid base64url"): + parse_general_json_jws( + {"payload": b64_payload, "signatures": [{"protected": b64_header, "signature": padded}]} + ) + + +def test_padded_header_rejected() -> None: + jwk, key = _ed25519() + b64_header = _b64_json(_header()) + padded = b64_header + "=" * (-len(b64_header) % 4) + assert padded != b64_header + token = _sign_raw(padded, _b64_json(PAYLOAD), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="protected header is not valid base64url"): + _verify(token, jwk) + + +def test_padded_payload_rejected_even_when_signed() -> None: + jwk, key = _ed25519() + b64_payload = _b64_json(PAYLOAD) + padded = b64_payload + "=" * (-len(b64_payload) % 4) + assert padded != b64_payload + token = _sign_raw(_b64_json(_header()), padded, key, ALG_ED25519) + with pytest.raises(JwsMalformedError): + _verify(token, jwk) + + +def test_standard_alphabet_signature_rejected() -> None: + _, key = _ed25519() + token = _sign(key) + b64_header, b64_payload, b64_signature = token.split(".") + std = b64_signature.replace("-", "+").replace("_", "/") + if std == b64_signature: # pragma: no cover - ~1 in 10^9 for 86 chars + pytest.skip("signature happened to contain no '-' or '_'") + with pytest.raises(JwsMalformedError, match="not valid base64url"): + parse_compact_jws(f"{b64_header}.{b64_payload}.{std}") + + +def test_non_canonical_trailing_bits_rejected() -> None: + # An Ed25519 signature is 64 bytes -> 86 chars with 2 unused trailing bits. + _, key = _ed25519() + token = _sign(key) + b64_header, b64_payload, b64_signature = token.split(".") + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + last = alphabet.index(b64_signature[-1]) + tweaked = b64_signature[:-1] + alphabet[last | 0b01] + if tweaked == b64_signature: + tweaked = b64_signature[:-1] + alphabet[last ^ 0b10] + # Same decoded bytes under a lenient decoder; a different wire string. + assert base64.urlsafe_b64decode(tweaked + "==") == base64.urlsafe_b64decode( + b64_signature + "==" + ) + with pytest.raises(JwsMalformedError, match="not valid base64url"): + parse_compact_jws(f"{b64_header}.{b64_payload}.{tweaked}") + + +# -- key / alg binding -------------------------------------------------- + + +def test_eddsa_with_ed448_key_rejected() -> None: + # RFC 8037 EdDSA also covers Ed448; the AdCP profile is Ed25519 only. + private_key = ed448.Ed448PrivateKey.generate() + jwk = { + "kty": "OKP", + "crv": "Ed448", + "kid": KID, + "x": b64url_encode(private_key.public_key().public_bytes_raw()), + } + b64_header, b64_payload = _b64_json(_header()), _b64_json(PAYLOAD) + signature = private_key.sign(f"{b64_header}.{b64_payload}".encode()) + token = f"{b64_header}.{b64_payload}.{b64url_encode(signature)}" + with pytest.raises(ValueError, match="unsupported JWK kty/crv"): + _verify(token, jwk) + + +def test_es256_header_with_ed25519_key_is_signature_invalid() -> None: + jwk, key = _ed25519() + token = _sign(key, header=_header(alg="ES256")) + with pytest.raises(JwsSignatureInvalidError): + _verify(token, jwk) + + +def test_es256_der_signature_rejected() -> None: + # JWS ES256 is raw r||s (RFC 7518 section 3.4); a DER signature must fail. + jwk, key = _es256() + b64_header, b64_payload = _b64_json(_header(alg="ES256")), _b64_json(PAYLOAD) + der = key.sign(f"{b64_header}.{b64_payload}".encode(), ec.ECDSA(hashes.SHA256())) + token = f"{b64_header}.{b64_payload}.{b64url_encode(der)}" + with pytest.raises(JwsSignatureInvalidError): + _verify(token, jwk) + + +def test_es256_raw_signature_general_json_round_trip() -> None: + jwk, key = _es256() + token = _sign(key, header=_header(alg="ES256"), internal_alg=ALG_ES256) + b64_header, b64_payload, b64_signature = token.split(".") + doc = { + "payload": b64_payload, + "signatures": [{"protected": b64_header, "signature": b64_signature}], + } + assert _verify(doc, jwk) == PAYLOAD + + +def test_jwk_with_encryption_use_rejected() -> None: + jwk, key = _ed25519() + jwk = {**jwk, "use": "enc", "key_ops": ["encrypt"]} + with pytest.raises(JwsSignatureInvalidError, match="designed to be used for 'enc'"): + _verify(_sign(key), jwk) + + +def test_jwk_without_verify_key_op_rejected() -> None: + jwk, key = _ed25519() + jwk = {**jwk, "key_ops": ["sign"]} + with pytest.raises(JwsSignatureInvalidError): + _verify(_sign(key), jwk) + + +def test_jwk_bound_to_other_alg_rejected() -> None: + jwk, key = _ed25519() + jwk = {**jwk, "alg": "ES256"} + with pytest.raises(JwsSignatureInvalidError): + _verify(_sign(key), jwk) + + +def test_jwk_with_rfc9864_ed25519_alg_accepted() -> None: + jwk, key = _ed25519() + jwk = {**jwk, "alg": "Ed25519"} + assert _verify(_sign(key), jwk) == PAYLOAD + + +def test_jwk_with_extra_members_accepted() -> None: + jwk, key = _ed25519() + jwk = {**jwk, "adcp_use": "governance-signing"} + assert _verify(_sign(key), jwk) == PAYLOAD + + +def test_unsupported_jwk_kty_raises_value_error() -> None: + # Unchanged contract: an unusable JWK is a ValueError, not a JwsError. + _, key = _ed25519() + with pytest.raises(ValueError, match="unsupported JWK kty/crv"): + _verify(_sign(key), {"kty": "RSA", "kid": KID, "n": "AQAB", "e": "AQAB"}) + + +def test_p256_jwk_with_overlong_coordinate_rejected() -> None: + jwk, key = _es256() + numbers = key.public_key().public_numbers() + jwk = {**jwk, "x": b64url_encode(numbers.x.to_bytes(33, "big"))} + token = _sign(key, header=_header(alg="ES256"), internal_alg=ALG_ES256) + with pytest.raises(ValueError, match="32 bytes"): + _verify(token, jwk) + + +# -- general JSON shape --------------------------------------------------- + + +def test_unprotected_header_is_ignored() -> None: + # kid only in the unprotected header: the profile reads protected only. + jwk, key = _ed25519() + token = _sign(key, header=_header(kid=None)) + b64_header, b64_payload, b64_signature = token.split(".") + doc = { + "payload": b64_payload, + "signatures": [ + {"protected": b64_header, "header": {"kid": KID}, "signature": b64_signature} + ], + } + with pytest.raises(JwsMalformedError, match="kid"): + _verify(doc, jwk) + + +def test_flattened_json_rejected() -> None: + jwk, key = _ed25519() + b64_header, b64_payload, b64_signature = _sign(key).split(".") + doc = {"payload": b64_payload, "protected": b64_header, "signature": b64_signature} + with pytest.raises(JwsMalformedError, match="must have"): + _verify(doc, jwk) + + +# -- size ----------------------------------------------------------------- + + +def test_payload_larger_than_joserfc_default_cap_accepted() -> None: + # joserfc caps payloads at 128 kB by default; the fetch layer's + # max_body_bytes (1 MiB default) is the bound that applies. + jwk, key = _ed25519() + payload = {**PAYLOAD, "revoked_jtis": [f"01HWZX{i:020d}" for i in range(8000)]} + token = _sign(key, payload=payload) + assert len(token) > 128_000 + assert _verify(token, jwk) == payload + + +def test_long_kid_header_accepted() -> None: + jwk, key = _ed25519() + long_kid = "k" * 600 + jwk = {**jwk, "kid": long_kid} + assert _verify(_sign(key, header=_header(kid=long_kid)), jwk) == PAYLOAD + + +# -- async paths ---------------------------------------------------------- + + +def _async_resolver(jwk: dict[str, Any]) -> Any: + async def resolve(kid: str) -> dict[str, Any] | None: + return jwk if kid == jwk["kid"] else None + + return resolve + + +async def test_async_document_rejects_duplicate_payload_keys() -> None: + jwk, key = _ed25519() + raw_payload = b'{"version":1,"version":2}' + token = _sign_raw(_b64_json(_header()), b64url_encode(raw_payload), key, ALG_ED25519) + with pytest.raises(JwsMalformedError, match="duplicate_key"): + await averify_jws_document(token, jwks_resolver=_async_resolver(jwk), expected_typ=TYP) + + +async def test_async_detached_round_trip_and_tamper() -> None: + jwk, key = _es256() + token = _sign(key, header=_header(alg="ES256"), internal_alg=ALG_ES256) + b64_header, b64_payload, signature = parse_compact_jws(token) + verified = await averify_detached_jws( + b64_protected=b64_header, + b64_payload=b64_payload, + signature=signature, + jwks_resolver=_async_resolver(jwk), + expected_typ=TYP, + ) + assert verified == PAYLOAD + with pytest.raises(JwsSignatureInvalidError): + await averify_detached_jws( + b64_protected=b64_header, + b64_payload=_b64_json({**PAYLOAD, "revoked_kids": []}), + signature=signature, + jwks_resolver=_async_resolver(jwk), + expected_typ=TYP, + ) diff --git a/tests/conformance/signing/test_keygen_programmatic.py b/tests/conformance/signing/test_keygen_programmatic.py index e7edddc21..77d634445 100644 --- a/tests/conformance/signing/test_keygen_programmatic.py +++ b/tests/conformance/signing/test_keygen_programmatic.py @@ -309,3 +309,77 @@ def test_signature_produced_with_pem_verifies_against_jwk(alg: str) -> None: jwks_resolver=StaticJwksResolver({"keys": [jwk]}), ) verify_request_signature(method="POST", url=url, headers=headers, body=body, options=options) + + +# --------------------------------------------------------------------------- +# Library delegation — key generation and JWK export run through joserfc. +# These pin the behavior that used to be hand-written so a joserfc upgrade +# that changes it fails here rather than at a verifier. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("alg", "expected_members"), + [ + ("ed25519", ["kty", "crv", "alg", "use", "key_ops", "adcp_use", "kid", "x"]), + ("es256", ["kty", "crv", "alg", "use", "key_ops", "adcp_use", "kid", "x", "y"]), + ], +) +def test_public_jwk_has_exactly_the_public_members_in_order( + alg: str, expected_members: list[str] +) -> None: + """Only public members, in the order the CLI prints them. No ``d`` and + no other private RFC 7518 member can leak from the library export.""" + _, jwk = generate_signing_keypair(alg=alg, kid="k") + assert list(jwk) == expected_members + + +@pytest.mark.parametrize("alg", ["ed25519", "es256"]) +def test_jwk_thumbprint_matches_pem_key_and_wba_helper(alg: str) -> None: + """The RFC 7638 thumbprint of the published JWK equals the thumbprint of + the PEM's key, and the AdCP members (``adcp_use``, ``key_ops`` ...) do + not perturb it. Cross-checks joserfc against ``thumbprint_key_id``.""" + from joserfc.jwk import ECKey, OKPKey + + from adcp.signing.wba import thumbprint_key_id + + pem, jwk = generate_signing_keypair(alg=alg, kid="k") + key_cls = OKPKey if alg == "ed25519" else ECKey + from_pem = key_cls.import_key(pem) + assert thumbprint_key_id(jwk) == from_pem.thumbprint() + + +@pytest.mark.parametrize("alg", ["ed25519", "es256"]) +def test_pem_is_pkcs8(alg: str) -> None: + """Plain PEM is unencrypted PKCS#8; encrypted PEM is encrypted PKCS#8.""" + pem, _ = generate_signing_keypair(alg=alg, kid="k") + assert pem.startswith(b"-----BEGIN " + b"PRIVATE KEY-----") + enc, _ = generate_signing_keypair(alg=alg, kid="k", passphrase=b"pw") + assert enc.startswith(b"-----BEGIN ENCRYPTED " + b"PRIVATE KEY-----") + + +def test_non_utf8_passphrase_is_used_byte_exact() -> None: + """``passphrase`` is ``bytes``; joserfc annotates its password as ``str`` + but must pass bytes through unchanged. A non-UTF-8 passphrase has to + round-trip with the same bytes.""" + passphrase = b"\xff\xfe\x00not-utf8" + pem, _ = generate_signing_keypair(alg="ed25519", kid="k", passphrase=passphrase) + key = serialization.load_pem_private_key(pem, password=passphrase) + assert isinstance(key, ed25519.Ed25519PrivateKey) + + +def test_pem_to_adcp_jwk_rejects_non_p256_public_pem() -> None: + """The curve allow-list is AdCP policy and stays in the SDK: joserfc + would happily export a P-384 JWK.""" + from adcp.signing import pem_to_adcp_jwk + + spki = ( + ec.generate_private_key(ec.SECP384R1()) + .public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + with pytest.raises(ValueError, match="is not supported"): + pem_to_adcp_jwk(spki, kid="k", purpose="request-signing") diff --git a/tests/conformance/signing/test_revocation_checker_boundary.py b/tests/conformance/signing/test_revocation_checker_boundary.py index 7ad1dcef3..4bc2e01db 100644 --- a/tests/conformance/signing/test_revocation_checker_boundary.py +++ b/tests/conformance/signing/test_revocation_checker_boundary.py @@ -267,13 +267,13 @@ def test_live_signed_issuer_grace_stale_rejection_and_recovery(monkeypatch): operational = [] replay = InMemoryReplayStore() _observe_crypto_and_replay(monkeypatch, replay, events) - original_jws_verify = jws.verify_signature + original_jws_verify = jws._verify_signature_and_decode_payload def verify_list(**kwargs): events.append("list_jws_crypto") return original_jws_verify(**kwargs) - monkeypatch.setattr(jws, "verify_signature", verify_list) + monkeypatch.setattr(jws, "_verify_signature_and_decode_payload", verify_list) class Issuer(BaseHTTPRequestHandler): def log_message(self, *args): diff --git a/tests/conformance/signing/test_standard_webhooks.py b/tests/conformance/signing/test_standard_webhooks.py index 3dff21d6f..e8f6584a1 100644 --- a/tests/conformance/signing/test_standard_webhooks.py +++ b/tests/conformance/signing/test_standard_webhooks.py @@ -207,3 +207,156 @@ def test_svix_python_signer_interop() -> None: } verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts_int)) + + +# ---------- Delegation to the ``standardwebhooks`` reference package ---------- + + +def _library_webhook() -> Any: + from standardwebhooks import Webhook + + return Webhook(_SECRET_WHSEC) + + +def test_library_signature_verifies_with_sdk_wrapper() -> None: + """Reference ``standardwebhooks.Webhook.sign`` → SDK verify.""" + body = '{"hello":"wörld","emoji":"\U0001f680"}'.encode() + now_dt = datetime.now(timezone.utc).replace(microsecond=0) + ts_int = int(now_dt.timestamp()) + headers = { + HEADER_ID: "msg_lib_1", + HEADER_TIMESTAMP: str(ts_int), + HEADER_SIGNATURE: _library_webhook().sign("msg_lib_1", now_dt, body.decode("utf-8")), + } + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts_int)) + + +def test_sdk_signature_verifies_with_library() -> None: + """SDK sign → reference ``standardwebhooks.Webhook.verify`` (wall clock).""" + body = '{"hello":"wörld"}'.encode() + headers = sign_standard_webhook( + secret=_SECRET_BYTES, msg_id="msg_lib_2", timestamp=int(time.time()), body=body + ) + assert _library_webhook().verify(body, headers, json_parse=False) is None + + +def test_signature_matches_spec_formula() -> None: + """Pin the wire value to the spec's HMAC formula, independent of the library.""" + import hashlib + import hmac + + body = b'{"n":1}' + headers = sign_standard_webhook( + secret=_SECRET_BYTES, msg_id="msg_1", timestamp=1_700_000_000, body=body + ) + digest = hmac.new(_SECRET_BYTES, b"msg_1.1700000000." + body, hashlib.sha256).digest() + assert headers[HEADER_SIGNATURE] == "v1," + base64.b64encode(digest).decode("ascii") + + +def test_injected_clock_is_authoritative() -> None: + """``now`` and ``tolerance_seconds`` decide; the library's fixed wall-clock window does not.""" + ts = 1_000_000_000 # 2001, far outside any wall-clock window + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts + 10)) + verify_standard_webhook( + headers=headers, + body=body, + secret=_SECRET_BYTES, + now=float(ts + 3600), + tolerance_seconds=3600, + ) + + +def test_skew_at_tolerance_boundary_accepted() -> None: + ts = 1_700_000_000 + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + for now in (ts - 300, ts + 300): + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(now)) + + +@pytest.mark.parametrize( + "transform", [lambda t: f"+{t}", lambda t: f"{t}.0", lambda t: f" {t}", lambda t: f"0{t}"] +) +def test_non_canonical_timestamp_rejected(transform: Any) -> None: + """The library would sign the floored integer, not the header bytes.""" + ts = int(time.time()) + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + headers[HEADER_TIMESTAMP] = transform(ts) + with pytest.raises(StandardWebhookError, match="invalid webhook-timestamp"): + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts)) + + +def test_header_names_case_insensitive() -> None: + ts = int(time.time()) + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + mixed = {k.title(): v for k, v in headers.items()} + assert HEADER_SIGNATURE not in mixed + verify_standard_webhook(headers=mixed, body=body, secret=_SECRET_BYTES, now=float(ts)) + + +def test_asymmetric_v1a_tokens_ignored() -> None: + ts = int(time.time()) + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + headers[HEADER_SIGNATURE] = f"v1a,AAAA {headers[HEADER_SIGNATURE]}" + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts)) + + +@pytest.mark.parametrize("bad_token", ["garbage", "v1,abc", "v2,a,b"]) +def test_malformed_signature_tokens_are_skipped_like_unknown_versions(bad_token: str) -> None: + """A malformed token never matches, and never hides a valid one beside it.""" + ts = int(time.time()) + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + valid = headers[HEADER_SIGNATURE] + headers[HEADER_SIGNATURE] = f"{bad_token} {valid}" + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts)) + headers[HEADER_SIGNATURE] = bad_token + with pytest.raises(StandardWebhookError, match="no matching v1 signature"): + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts)) + + +def test_signature_value_must_match_exactly() -> None: + """Junk inside a correct signature is not lenient-decoded into a match.""" + ts = int(time.time()) + body = b"body" + headers = sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=ts, body=body) + version, _, value = headers[HEADER_SIGNATURE].partition(",") + headers[HEADER_SIGNATURE] = f"{version},{value[:4]}*{value[4:]}" + with pytest.raises(StandardWebhookError, match="no matching v1 signature"): + verify_standard_webhook(headers=headers, body=body, secret=_SECRET_BYTES, now=float(ts)) + + +def test_non_utf8_body_rejected() -> None: + with pytest.raises(StandardWebhookError, match="UTF-8"): + sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=1, body=b"\xff") + headers = {HEADER_ID: "msg_1", HEADER_TIMESTAMP: "1", HEADER_SIGNATURE: "v1,AAAA"} + with pytest.raises(StandardWebhookError, match="UTF-8"): + verify_standard_webhook(headers=headers, body=b"\xff", secret=_SECRET_BYTES, now=1.0) + + +def test_empty_key_rejected() -> None: + with pytest.raises(StandardWebhookError, match="non-empty"): + decode_secret(SECRET_PREFIX) + with pytest.raises(StandardWebhookError, match="non-empty"): + sign_standard_webhook(secret=b"", msg_id="msg_1", timestamp=1, body=b"") + + +def test_string_secret_rejected() -> None: + """The library would base64-decode a ``str``; the SDK requires raw bytes.""" + with pytest.raises(TypeError, match="raw key bytes"): + sign_standard_webhook( + secret=_SECRET_WHSEC, # type: ignore[arg-type] + msg_id="msg_1", + timestamp=1, + body=b"", + ) + + +def test_bool_timestamp_rejected() -> None: + with pytest.raises(StandardWebhookError, match="timestamp must be an int"): + sign_standard_webhook(secret=_SECRET_BYTES, msg_id="msg_1", timestamp=True, body=b"") diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py new file mode 100644 index 000000000..8169c7e8f --- /dev/null +++ b/tests/conformance/signing/test_wba_interop.py @@ -0,0 +1,267 @@ +"""Web Bot Auth interoperability with Cloudflare's web-bot-auth and the drafts. + +The fixtures under ``vectors/wba-interop`` were generated by adcp-client with +Cloudflare's ``web-bot-auth@0.2.0`` and ``http-message-sig@0.3.0`` (see the +README there). The RSA-PSS vector is draft-ietf-webbotauth-httpsig-protocol +Appendix E.1.1, signed with RFC 9421's ``test-key-rsa-pss``. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from http_sfv.dictionary import Dictionary + +from adcp.signing import InMemoryReplayStore +from adcp.signing.wba import ( + ALG_RSA_PSS_SHA512, + DirectoryResponse, + DiscoveredSignerKeys, + WbaVerificationError, + _algorithm, + _base, + _verify_crypto, + is_64_byte_nonce, + public_directory_key, + sign_key_directory, + sign_wba_request, + thumbprint_key_id, + valid_wba_nonce, + verify_directory_response, + verify_wba_request, +) + +ROOT = Path(__file__).parents[1] / "vectors" / "wba-interop" +FIXTURES = sorted(path for path in ROOT.glob("*-signed-*.json")) + + +def test_vendored_fixtures_match_recorded_provenance(): + upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) + vendored = {path.name for path in ROOT.glob("*.json")} - {"UPSTREAM.json"} + assert vendored == set(upstream["sha256"]) + for name, digest in upstream["sha256"].items(): + assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest + assert {path.name.split("-signed-")[0] for path in FIXTURES} == {"adcp-client", "cloudflare"} + + +def _directory(fixture): + raw = fixture["directory"] + return DirectoryResponse( + body=raw["body"].encode(), headers=dict(raw["headers"]), status=raw["status"] + ) + + +@pytest.mark.parametrize("path", FIXTURES, ids=lambda path: path.stem) +async def test_python_verifies_foreign_signed_requests(path): + fixture = json.loads(path.read_text()) + request, expected = fixture["request"], fixture["expected"] + now = fixture["reference_now"] + directory_url = fixture["directory"]["url"] + + async def fetch(url, *, allow_private=False): + assert url == directory_url + return _directory(fixture) + + (signer,) = await verify_wba_request( + method=request["method"], + url=request["url"], + headers=request["headers"], + body=request["body"].encode(), + signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now), + replay_store=InMemoryReplayStore(), + now=now, + ) + assert expected["verifies"] is True + assert signer.agent_url == expected["identity"] + assert signer.key_id == expected["keyid"] + # @target-uri is the URI as received: %7E stays encoded. + label = next(iter(_parse_dictionary(request["headers"]["signature-input"]))) + member = _parse_dictionary(request["headers"]["signature-input"])[label] + base = _base(request["method"], request["url"], request["headers"], member).decode() + assert base == expected["signature_base"] + + +def _parse_dictionary(value: str) -> Dictionary: + parsed = Dictionary() + parsed.parse(value.encode("ascii")) + return parsed + + +@pytest.mark.parametrize("path", FIXTURES, ids=lambda path: path.stem) +def test_foreign_signed_directories_verify(path): + fixture = json.loads(path.read_text()) + origin = fixture["directory"]["url"].split("/.well-known/")[0] + keys = verify_directory_response( + origin, _directory(fixture), now=fixture["reference_now"], require_signed=True + ) + assert fixture["expected"]["keyid"] in keys + + +def test_cloudflare_padded_base64_nonce_is_64_bytes(): + fixture = json.loads((ROOT / "cloudflare-signed-body-request.json").read_text()) + member = _parse_dictionary(fixture["request"]["headers"]["signature-input"])["sig1"] + nonce = member.params["nonce"] + assert nonce.endswith("==") and is_64_byte_nonce(nonce) and valid_wba_nonce(nonce) + + +@pytest.mark.parametrize("path", ["/mcp", "/a%7Eb/mcp/"]) +async def test_python_signed_requests_verify_with_the_target_uri_as_sent(path): + keys = json.loads((ROOT / "keys.json").read_text())["keys"] + from adcp.signing.crypto import private_key_from_jwk + + jwk = next(key for key in keys if key["kty"] == "OKP") + private = private_key_from_jwk({**jwk, **jwk["_private_jwk_for_test_only"]}) + origin = "https://buyer.agency.com" + url = "https://seller.example.com" + path + body = b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_products"}}' + headers = {"content-type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url=url, + headers=headers, + body=body, + private_key=private, + agent_origin=origin, + created=1790841610, + ) + ) + member = _parse_dictionary(headers["Signature-Input"])["sig1"] + # Default nonce: 64 random bytes, unpadded base64url. + assert len(member.params["nonce"]) == 86 and is_64_byte_nonce(member.params["nonce"]) + base = _base("POST", url, {k.lower(): v for k, v in headers.items()}, member).decode() + assert f'"@target-uri": {url}\n' in base + assert '"@authority": seller.example.com\n' in base + directory = sign_key_directory(agent_origin=origin, private_keys=[private], created=1790841550) + + async def fetch(url, *, allow_private=False): + return directory + + (signer,) = await verify_wba_request( + method="POST", + url=url, + headers=headers, + body=body, + signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1790841610), + replay_store=InMemoryReplayStore(), + now=1790841610, + ) + assert signer.agent_url == origin + # A decoded (canonicalized) target URI is a different signature base. + if "%7E" in path: + with pytest.raises(WbaVerificationError, match="did not verify"): + await verify_wba_request( + method="POST", + url=url.replace("%7E", "~"), + headers=headers, + body=body, + signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1790841610), + replay_store=InMemoryReplayStore(), + now=1790841610, + ) + + +# RFC 9421 Appendix B.1.2 test-key-rsa-pss, the key the Web Bot Auth draft's +# Appendix E vectors sign with. +RFC9421_TEST_KEY_RSA_PSS = b"""-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr4tmm3r20Wd/PbqvP1s2 ++QEtvpuRaV8Yq40gjUR8y2Rjxa6dpG2GXHbPfvMs8ct+Lh1GH45x28Rw3Ry53mm+ +oAXjyQ86OnDkZ5N8lYbggD4O3w6M6pAvLkhk95AndTrifbIFPNU8PPMO7OyrFAHq +gDsznjPFmTOtCEcN2Z1FpWgchwuYLPL+Wokqltd11nqqzi+bJ9cvSKADYdUAAN5W +Utzdpiy6LbTgSxP7ociU4Tn0g5I6aDZJ7A8Lzo0KSyZYoA485mqcO0GVAdVw9lq4 +aOT9v6d+nb4bnNkQVklLQ3fVAvJm+xdDOp9LCNCN48V2pnDOkFV6+U9nV5oyc6XI +2wIDAQAB +-----END PUBLIC KEY-----""" +APPENDIX_E_NONCE = ( + "wcfPQPh7SzkvrIVvhD00vNk9PkxJNY2NVbYl2PVBB4zmUoluSwE7W6bPtF60QA3k8g06FU7PPCD+J58YofY1zg==" +) +APPENDIX_E_INPUT = ( + 'sig2=("@authority" "signature-agent";key="agent2");created=1735689600;' + 'keyid="oD0HwocPBSfpNy5W3bpJeyFGY_IQ_YpqxSjQ3Yd-CLA";alg="rsa-pss-sha512";' + f'expires=4889289600;nonce="{APPENDIX_E_NONCE}";tag="web-bot-auth"' +) +APPENDIX_E_SIGNATURE = ( + "sig2=:gHzpLNeHaHIO19NaJH9YMW5dcVSi2s0wOMBr6p18vcofS106sfC4KBIS0/szPlBBd1vIcyQ88B6CTEWIhRAiV" + "rb9zfX0mx1aG12CSGWcYkSirHeyTxhbuJvXd27ed6skWoy4PjXItq38936ivUQjfdIwXh1aX6HxkAC3vRnEdSNfn" + "tkLWeEuIQ5BLIOBGE39fSwg27Qjq6OVWYas/9/aFUr3HA34MXWYdp+//cvlEKDp3kRoLOw9ro0AOr6srHrTeEtxo" + "n2afcws1aZVSlPdd2fZSEIGmw9HAHLDCEkFTERu1gH2k/zIEqgy7CAYXI9E5slog0cLg/Vc6+f8gih33g==:" +) + + +def _rsa_jwk(public: rsa.RSAPublicKey) -> dict[str, str]: + from adcp.signing.crypto import b64url_encode + + numbers = public.public_numbers() + return { + "kty": "RSA", + "n": b64url_encode(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")), + "e": b64url_encode(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")), + } + + +def test_rsa_pss_appendix_e_vector(): + public = serialization.load_pem_public_key(RFC9421_TEST_KEY_RSA_PSS) + assert isinstance(public, rsa.RSAPublicKey) + jwk = _rsa_jwk(public) + assert thumbprint_key_id(jwk) == "oD0HwocPBSfpNy5W3bpJeyFGY_IQ_YpqxSjQ3Yd-CLA" + assert _algorithm(jwk) == ALG_RSA_PSS_SHA512 + assert is_64_byte_nonce(APPENDIX_E_NONCE), "padded standard base64 of 64 bytes" + member = _parse_dictionary(APPENDIX_E_INPUT)["sig2"] + signature = _parse_dictionary(APPENDIX_E_SIGNATURE)["sig2"] + headers = {"signature-agent": 'agent2="https://signature-agent.test"'} + base = _base("GET", "https://example.com/", headers, member) + assert base.decode() == ( + '"@authority": example.com\n' + '"signature-agent";key="agent2": "https://signature-agent.test"\n' + f'"@signature-params": {APPENDIX_E_INPUT.removeprefix("sig2=")}' + ) + assert _verify_crypto(member, signature, jwk, base) == ALG_RSA_PSS_SHA512 + tampered = base.replace(b"example.com", b"example.net", 1) + with pytest.raises(WbaVerificationError, match="did not verify"): + _verify_crypto(member, signature, jwk, tampered) + + +async def test_rsa_pss_identity_round_trip_and_key_bounds(): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + origin = "https://buyer.example.com" + directory = sign_key_directory(agent_origin=origin, private_keys=[private], created=1000) + assert 'alg="rsa-pss-sha512"' in directory.headers["Signature-Input"] + body = b'{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + 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, + created=1000, + ) + ) + + async def fetch(url, *, allow_private=False): + return directory + + (signer,) = await verify_wba_request( + method="POST", + url="https://seller.example.com/mcp", + headers=headers, + body=body, + signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1001), + replay_store=InMemoryReplayStore(), + now=1001, + ) + assert signer.alg == ALG_RSA_PSS_SHA512 + small = rsa.generate_private_key(public_exponent=65537, key_size=1024) + with pytest.raises(ValueError, match="2048"): + public_directory_key(small) + with pytest.raises(WbaVerificationError): + _algorithm(_rsa_jwk(small.public_key())) + with pytest.raises(WbaVerificationError): + _algorithm({**public_directory_key(private), "e": "AQAA"}) # even exponent diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 24ca7dee0..57f4abaf6 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -60,17 +60,43 @@ def _mcp_adcp_error(response): return content["adcp_error"] -async def test_standalone_verification_requires_origin_revocation_checker(): - with pytest.raises(ValueError, match="origin revocation checker"): +async def test_standalone_verification_needs_only_directory_and_replay_store(): + """Verification is usable on its own; revocation is the authorization layer.""" + with pytest.raises(ValueError, match="replay store"): await verify_wba_request( method="POST", url="https://seller.com/mcp", headers={}, body=b"", signer_keys=StaticSignerKeys({}), - replay_store=InMemoryReplayStore(), - revocation_checker=None, + replay_store=None, ) + origin = "https://buyer.example.com" + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + headers = {"Content-Type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url="https://seller.example.com/mcp", + headers=headers, + body=b"{}", + private_key=private, + agent_origin=origin, + created=1000, + ) + ) + (signer,) = await verify_wba_request( + method="POST", + url="https://seller.example.com/mcp", + headers=headers, + body=b"{}", + signer_keys=StaticSignerKeys({origin: {"keys": [jwk]}}), + replay_store=InMemoryReplayStore(), + now=1001, + ) + assert signer.agent_url == origin + assert signer.revocation is None def _trust_records(origin): @@ -117,6 +143,9 @@ async def verify(): replay_store=replay, revocation_checker=_not_revoked, now=vector["reference_now"], + # The spec answers a reused nonce 429; #7894's older replay vector + # predates that and expects 403, reproduced by the compat switch. + replay_status=403 if "replayed-nonce" in path.name else 429, ) expected = vector["expected_outcome"] @@ -226,7 +255,8 @@ def test_signed_directory_rejects_tampering(tamper): elif tamper == "authority": origin = "https://attacker.example.com" elif tamper == "future": - now = 999 + # Directory signatures tolerate 60 s of publisher clock skew only. + now = 939 else: data = json.loads(response.body) if tamper == "kid": @@ -258,7 +288,7 @@ async def resolve(keyid, *, signer_origin=None): private_key=key, agent_origin=origin, created=1000, - nonce="a" * 43, + nonce="A" * 86, ) ) if index == 0: @@ -273,7 +303,7 @@ async def resolve(keyid, *, signer_origin=None): now=1001, ) else: - with pytest.raises(WbaVerificationError, match="replayed"): + with pytest.raises(WbaVerificationError, match="replayed") as replayed: await verify_wba_request( method="POST", url="https://seller.example.com/mcp", @@ -284,6 +314,9 @@ async def resolve(keyid, *, signer_origin=None): revocation_checker=_not_revoked, now=1001, ) + # A reused nonce is 429 with no re-sign challenge. + assert replayed.value.status == 429 + assert "Accept-Signature" not in replayed.value.headers async def test_framework_requires_explicit_binding_before_dispatch(): @@ -315,6 +348,7 @@ async def app(scope, receive, send): wba_revocation_checker=_not_revoked, trust_records=_trust_records(origin), signing_profile_version="3.2", + required_for=frozenset({"sync_accounts"}), ) app = SignedRequestVerificationMiddleware( app, config, transport="mcp", defer_authorization_errors=True @@ -625,8 +659,18 @@ async def verify(): await verify() -@pytest.mark.parametrize("revocation_state", ["clear", "revoked", "unavailable"]) -async def test_framework_checks_origin_revocations_by_default(monkeypatch, revocation_state): +@pytest.mark.parametrize( + ("revocation_state", "operation"), + [ + ("clear", "sync_accounts"), + ("revoked", "sync_accounts"), + ("unavailable", "create_media_buy"), + ("unavailable", "get_media_buys"), + ], +) +async def test_framework_checks_origin_revocations_by_default( + monkeypatch, revocation_state, operation +): from datetime import datetime, timedelta, timezone from adcp.signing import OriginRevocationChecker @@ -686,10 +730,14 @@ async def dispatch(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":{}}}' - ) + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": operation, "arguments": {}}, + } + ).encode() headers = {"Content-Type": "application/json"} headers.update( sign_wba_request( @@ -706,15 +754,22 @@ async def dispatch(scope, receive, send): ) 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" + # A revoked key: 403, the generic authentication error, and no + # Accept-Signature, since signing again with the same key is wrong. + assert response.status_code == 403, response.text + assert response.json()["errors"][0]["code"] == "AUTH_INVALID" assert "Accept-Signature" not in response.headers - elif revocation_state == "unavailable": + assert not dispatched + elif revocation_state == "unavailable" and operation == "create_media_buy": + # A stale list fails spend closed in the authorization layer. + assert response.status_code == 200, response.text error = _mcp_adcp_error(response) assert error["code"] == "SERVICE_UNAVAILABLE" assert error["recovery"] == "transient" assert "Accept-Signature" not in response.headers + assert not dispatched + else: + # A current list, or a stale one for a non-spend call, proceeds. + assert response.status_code == 200, response.text + assert dispatched == [True] diff --git a/tests/conformance/vectors/wba-interop/README.md b/tests/conformance/vectors/wba-interop/README.md new file mode 100644 index 000000000..94d2ab3bc --- /dev/null +++ b/tests/conformance/vectors/wba-interop/README.md @@ -0,0 +1,25 @@ +# Web Bot Auth interoperability fixtures + +Copied without modification from adcontextprotocol/adcp-client +`test/fixtures/wba-interop/` at commit +`577f21e82a7c9f640e69d073f0856700f3601fe7` (adcp-client#3250, on `main` as of +`d6fe8b19585d846027aa6c896182b129cc04218d`), on 2026-10-15. `UPSTREAM.json` +records each file's SHA-256. + +The fixtures were generated by adcp-client's `test/lib/wba-interop.test.js` +against Cloudflare's reference packages (`web-bot-auth@0.2.0`, +`http-message-sig@0.3.0`). Each is a request signed by one implementation, the +key directory to serve for the signer's origin, the verifier clock +(`reference_now`), and the expected outcome, including the RFC 9421 signature +base. + +- `adcp-client-signed-*`: signed by adcp-client's `signWebBotAuth`, directory + from `createSignedSignatureDirectory`. +- `cloudflare-signed-*`: signed by `web-bot-auth`'s `sign()` (padded base64 + nonce), directory signed with `http-message-sig` (Appendix B). +- `*-percent-encoded-path`: the target URI is `/a%7Eb/mcp/`; `@target-uri` is + the URI as received (RFC 9421 §2.2.2), never percent-decoded. + +`keys.json` holds test-only keys (`_private_jwk_for_test_only`). Never use them +outside tests. `tests/conformance/signing/test_wba_interop.py` verifies every +fixture with this SDK. diff --git a/tests/conformance/vectors/wba-interop/UPSTREAM.json b/tests/conformance/vectors/wba-interop/UPSTREAM.json new file mode 100644 index 000000000..1d0fde32a --- /dev/null +++ b/tests/conformance/vectors/wba-interop/UPSTREAM.json @@ -0,0 +1,14 @@ +{ + "repository": "adcontextprotocol/adcp-client", + "path": "test/fixtures/wba-interop/", + "commit": "577f21e82a7c9f640e69d073f0856700f3601fe7", + "pull_request": 3250, + "retrieved": "2026-10-15", + "sha256": { + "adcp-client-signed-body-request.json": "1cddbc211fb3544f7b5266af8b61ad33969c8112bf2551b906f11d39ad437e69", + "adcp-client-signed-percent-encoded-path.json": "20f2751e832ff20c8bfe8893efa87602f5453acfe30ba862e8ad9afb6c5616ab", + "cloudflare-signed-body-request.json": "d7ed4ee6548b6f5e87b3cfa1ef5881a5e357ec3888aaf31edd831e3e73ce57b1", + "cloudflare-signed-percent-encoded-path.json": "3fc3bcef09e9b3539fc9e4ffcad9e558335e6571c177cc11d0036dbaa6c12b0f", + "keys.json": "df42015a43758bc577f46e491d20d274195ede7dcbce2f950a7547b853652c51" + } +} diff --git a/tests/conformance/vectors/wba-interop/adcp-client-signed-body-request.json b/tests/conformance/vectors/wba-interop/adcp-client-signed-body-request.json new file mode 100644 index 000000000..1302d62a2 --- /dev/null +++ b/tests/conformance/vectors/wba-interop/adcp-client-signed-body-request.json @@ -0,0 +1,34 @@ +{ + "signer": "adcp-client", + "request": { + "method": "POST", + "url": "https://seller.example.com/mcp", + "headers": { + "content-type": "application/json", + "content-digest": "sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:", + "signature-agent": "sig1=\"https://buyer.agency.com\"", + "signature-input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841610;expires=1790841910;nonce=\"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBw\";keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "signature": "sig1=:CvJAfYFW7nVZyaNmqGhAxlU9zni8komBpINNcirsQfFStl6OiedwhyBD+Yim+6x5blq8H0xbppepTtKruwz0Bw==:" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_products\",\"arguments\":{}}}" + }, + "directory": { + "url": "https://buyer.agency.com/.well-known/http-message-signatures-directory", + "status": 200, + "headers": { + "content-type": "application/http-message-signatures-directory+json", + "content-digest": "sha-256=:hY9mzpPJHdRN7bw4BNaKRTEOC9OfeMtCiuerhgPwPJg=:", + "cache-control": "max-age=300", + "signature-input": "key1=(\"@authority\";req \"content-digest\");created=1790841550;expires=1790928010;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"", + "signature": "key1=:P5KcRAfjjJC4u8oekfRI+pqkLcQGWLxhlDENdt5/iBn5E54XxOZW7N0ztVWznhtPMlbaYdxGhDKnv/2W08XNAw==:" + }, + "body": "{\"keys\":[{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"Ucrv9QJwrEpSnGIJFVNix5nM0Xa3xXTRUmsfsiVlZ_8\",\"kid\":\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\",\"use\":\"sig\"}]}" + }, + "reference_now": 1790841610, + "expected": { + "verifies": true, + "identity": "https://buyer.agency.com", + "keyid": "REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE", + "signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/mcp\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:\n\"signature-agent\";key=\"sig1\": \"https://buyer.agency.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841610;expires=1790841910;nonce=\"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBw\";keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"web-bot-auth\"" + } +} diff --git a/tests/conformance/vectors/wba-interop/adcp-client-signed-percent-encoded-path.json b/tests/conformance/vectors/wba-interop/adcp-client-signed-percent-encoded-path.json new file mode 100644 index 000000000..c3a617566 --- /dev/null +++ b/tests/conformance/vectors/wba-interop/adcp-client-signed-percent-encoded-path.json @@ -0,0 +1,34 @@ +{ + "signer": "adcp-client", + "request": { + "method": "POST", + "url": "https://seller.example.com/a%7Eb/mcp/", + "headers": { + "content-type": "application/json", + "content-digest": "sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:", + "signature-agent": "sig1=\"https://buyer.agency.com\"", + "signature-input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841610;expires=1790841910;nonce=\"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBw\";keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "signature": "sig1=:igFBT24wWxfcRgSVH2rxHe0uc/WPKbYoQQFu13c79lLsvFz+SKqvE1lfiSS5vxaEKHaDDLqMcgutEMT4MbNsBw==:" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_products\",\"arguments\":{}}}" + }, + "directory": { + "url": "https://buyer.agency.com/.well-known/http-message-signatures-directory", + "status": 200, + "headers": { + "content-type": "application/http-message-signatures-directory+json", + "content-digest": "sha-256=:hY9mzpPJHdRN7bw4BNaKRTEOC9OfeMtCiuerhgPwPJg=:", + "cache-control": "max-age=300", + "signature-input": "key1=(\"@authority\";req \"content-digest\");created=1790841550;expires=1790928010;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"", + "signature": "key1=:P5KcRAfjjJC4u8oekfRI+pqkLcQGWLxhlDENdt5/iBn5E54XxOZW7N0ztVWznhtPMlbaYdxGhDKnv/2W08XNAw==:" + }, + "body": "{\"keys\":[{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"Ucrv9QJwrEpSnGIJFVNix5nM0Xa3xXTRUmsfsiVlZ_8\",\"kid\":\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\",\"use\":\"sig\"}]}" + }, + "reference_now": 1790841610, + "expected": { + "verifies": true, + "identity": "https://buyer.agency.com", + "keyid": "REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE", + "signature_base": "\"@method\": POST\n\"@target-uri\": https://seller.example.com/a%7Eb/mcp/\n\"@authority\": seller.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:\n\"signature-agent\";key=\"sig1\": \"https://buyer.agency.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841610;expires=1790841910;nonce=\"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBw\";keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"web-bot-auth\"" + } +} diff --git a/tests/conformance/vectors/wba-interop/cloudflare-signed-body-request.json b/tests/conformance/vectors/wba-interop/cloudflare-signed-body-request.json new file mode 100644 index 000000000..bcae8483c --- /dev/null +++ b/tests/conformance/vectors/wba-interop/cloudflare-signed-body-request.json @@ -0,0 +1,33 @@ +{ + "signer": "cloudflare/web-bot-auth@0.2.0", + "request": { + "method": "POST", + "url": "https://seller.example.com/mcp", + "headers": { + "content-type": "application/json", + "content-digest": "sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:", + "signature-agent": "sig1=\"https://buyer.agency.com\"", + "signature-input": "sig1=(\"@authority\" \"signature-agent\";key=\"sig1\" \"@method\" \"@target-uri\" \"content-type\" \"content-digest\");created=1790841610;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";expires=1790841910;nonce=\"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQ==\";tag=\"web-bot-auth\"", + "signature": "sig1=:vRxxjxHjBKLemzp2kZtni9/35WegUzvf7xaOWh7Ys1nmbOgURmRgrCagmacgy38FX+bt6zrbYps2MGnkHBwBAg==:" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_products\",\"arguments\":{}}}" + }, + "directory": { + "url": "https://buyer.agency.com/.well-known/http-message-signatures-directory", + "status": 200, + "headers": { + "content-type": "application/http-message-signatures-directory+json", + "content-digest": "sha-256=:uLsL3sGtLI5QkHxNhv7nyPI8VdxX5fkhY73Nt1ewHBw=:", + "signature-input": "key1=(\"@authority\";req \"content-digest\");created=1790841550;expires=1790928010;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"", + "signature": "key1=:vOOCZCJDjftgx4iv1QQHAcFiJ8geVFylLZvRJOoWXUz6mfxUH56me4EHItvFIt/cGIf/VJZI1Nbm8Zo0jIcbCQ==:" + }, + "body": "{\"keys\":[{\"crv\":\"Ed25519\",\"x\":\"Ucrv9QJwrEpSnGIJFVNix5nM0Xa3xXTRUmsfsiVlZ_8\",\"kty\":\"OKP\",\"kid\":\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\",\"alg\":\"EdDSA\"}]}" + }, + "reference_now": 1790841610, + "expected": { + "verifies": true, + "identity": "https://buyer.agency.com", + "keyid": "REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE", + "signature_base": "\"@authority\": seller.example.com\n\"signature-agent\";key=\"sig1\": \"https://buyer.agency.com\"\n\"@method\": POST\n\"@target-uri\": https://seller.example.com/mcp\n\"content-type\": application/json\n\"content-digest\": sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:\n\"@signature-params\": (\"@authority\" \"signature-agent\";key=\"sig1\" \"@method\" \"@target-uri\" \"content-type\" \"content-digest\");created=1790841610;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";expires=1790841910;nonce=\"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQ==\";tag=\"web-bot-auth\"" + } +} diff --git a/tests/conformance/vectors/wba-interop/cloudflare-signed-percent-encoded-path.json b/tests/conformance/vectors/wba-interop/cloudflare-signed-percent-encoded-path.json new file mode 100644 index 000000000..09271bcdb --- /dev/null +++ b/tests/conformance/vectors/wba-interop/cloudflare-signed-percent-encoded-path.json @@ -0,0 +1,33 @@ +{ + "signer": "cloudflare/web-bot-auth@0.2.0", + "request": { + "method": "POST", + "url": "https://seller.example.com/a%7Eb/mcp/", + "headers": { + "content-type": "application/json", + "content-digest": "sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:", + "signature-agent": "sig1=\"https://buyer.agency.com\"", + "signature-input": "sig1=(\"@authority\" \"signature-agent\";key=\"sig1\" \"@method\" \"@target-uri\" \"content-type\" \"content-digest\");created=1790841610;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";expires=1790841910;nonce=\"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQ==\";tag=\"web-bot-auth\"", + "signature": "sig1=:afd9qcD+/VXi8D1hnshkh4pCA5gmwDD5MD+9/k+eRFOeasM+uz8FQVHeZa2UTDcaP4vceJFe3O0O1EzUxT0BCQ==:" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_products\",\"arguments\":{}}}" + }, + "directory": { + "url": "https://buyer.agency.com/.well-known/http-message-signatures-directory", + "status": 200, + "headers": { + "content-type": "application/http-message-signatures-directory+json", + "content-digest": "sha-256=:uLsL3sGtLI5QkHxNhv7nyPI8VdxX5fkhY73Nt1ewHBw=:", + "signature-input": "key1=(\"@authority\";req \"content-digest\");created=1790841550;expires=1790928010;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"", + "signature": "key1=:vOOCZCJDjftgx4iv1QQHAcFiJ8geVFylLZvRJOoWXUz6mfxUH56me4EHItvFIt/cGIf/VJZI1Nbm8Zo0jIcbCQ==:" + }, + "body": "{\"keys\":[{\"crv\":\"Ed25519\",\"x\":\"Ucrv9QJwrEpSnGIJFVNix5nM0Xa3xXTRUmsfsiVlZ_8\",\"kty\":\"OKP\",\"kid\":\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\",\"alg\":\"EdDSA\"}]}" + }, + "reference_now": 1790841610, + "expected": { + "verifies": true, + "identity": "https://buyer.agency.com", + "keyid": "REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE", + "signature_base": "\"@authority\": seller.example.com\n\"signature-agent\";key=\"sig1\": \"https://buyer.agency.com\"\n\"@method\": POST\n\"@target-uri\": https://seller.example.com/a%7Eb/mcp/\n\"content-type\": application/json\n\"content-digest\": sha-256=:LnHsFLYI6OsiiVB4APC5/RclcwyijwDcB/pUHmff3Rs=:\n\"@signature-params\": (\"@authority\" \"signature-agent\";key=\"sig1\" \"@method\" \"@target-uri\" \"content-type\" \"content-digest\");created=1790841610;keyid=\"REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE\";alg=\"ed25519\";expires=1790841910;nonce=\"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQ==\";tag=\"web-bot-auth\"" + } +} diff --git a/tests/conformance/vectors/wba-interop/keys.json b/tests/conformance/vectors/wba-interop/keys.json new file mode 100644 index 000000000..ddd5d8382 --- /dev/null +++ b/tests/conformance/vectors/wba-interop/keys.json @@ -0,0 +1,30 @@ +{ + "description": "Test-only keys for Web Bot Auth interoperability fixtures. Never use in production.", + "keys": [ + { + "crv": "Ed25519", + "x": "Ucrv9QJwrEpSnGIJFVNix5nM0Xa3xXTRUmsfsiVlZ_8", + "kty": "OKP", + "kid": "REA4imWeSrPHmj21jGEDpapbqhZo94UGbgAf5ZIQPJE", + "alg": "EdDSA", + "_private_jwk_for_test_only": { + "d": "YcmMz0RUw4NqyuORiprefF2ewmdQ1yhu-_NtAiupeOI" + } + }, + { + "kty": "RSA", + "n": "mAHmlVb3PNB62rKcpGGDBmisOF_C63mjv-6ogIyMgQjYi8gvvqgEq0pq_wcaFf2VpecVdVOs0tln4KVeqEgl_UaByKs1hZNCDQ0kUaGdSPaWhjjM7VKddMfLbUXOYcoSCpqYQ4lOSQuH8FTXAJ_GNTqL8d3nBlA-VKPuJ0w2ty6Fwg0eYKDUbq1Ytit_X5I2J_74V47_4lDr2Ic566SN8kd5v2Py2PFSEX8N0QbDKxorOCFoiXlyhWn-ijVkqFhAMEYm7cpy0_sK_CbANgCJCMzM3oS0gu3XYWAQe_n2o7tMauI6WxnXXvg2EaSrH8zpObqdpj_QItptBEIPnvQosw", + "e": "AQAB", + "kid": "3IV1cli3xGtmeIX5U2_XWS7e5Hn9UcvvqaK_uiTcpO8", + "alg": "PS512", + "_private_jwk_for_test_only": { + "d": "Or3RS7jVxkySjV0kp2l_ZsSgeRCYlMLnAs7JkxO9YEf41tu_h6AKXd9eRbuK2yCp6fjJFBQH31tghzGgyMtI0v2iPdfb0Qi9J6oCdQFNdNrGBIyCtNuOOYjUFiA5xxR9sFdZrA-tjxDDsZ43j-gugndjiU5Fbu0niTHoYItVQahCVVinzyuurgqB9N9XtFnzjRvX4z5g9HlIy8EmvF2CBrTBdxDUc_ky3_obJA4--nIhx-5DFS-DJ3U_I6e4SPyvBHKJUsh-nT5yKlAhFhJXi0j8Lt_6jmDOsTC7FSPw7pr_KV2lLjJHhUZISrp80ehxF6rx3SCE5iTZoReYnJdU8Q", + "p": "xbIqfqYBY4YFpc-qsxA_BXiKgOODLpRy4jkIb5lIwiWQCHqg7LoQ1SRr87uSjzL0x3kTJuw37oIH_xP_4Jt9GGWm1ktONpGjDmVLNAhGEuEyDUP6LzNZPNmwXncWZ_4uQvnJTr_j1Tr7gImXigYY4s_V7kMRE5lPDH1hmMX4Wn0", + "q": "xNZMi8wipZhx16j4tz9bhTMHfYHZ6-unXBM8DRTBvksAGTAJe5wueWWDjebcglItt2vYOleLOJ3U9mAfw3to30i8kgV3bhMwOh7KIItqtjljuY91_JV5lW9-Pk1F_XJ3AN3K64brT8vTVJ_HBgGnN80YPdn9h5BMm4TSlB53xu8", + "dp": "M_dYWkAyv0F8fJBw827C0iJUbfrgAb99z19ki0lEqdwL1168rF1gjVZslf3pUYD4fPKjiSiwrx45EeDk8tqMcHxP9aaH4ao4rVENCpleKhpN-NS0j_G0dF1YkgxFtIJJEMvuEag5MFS3xZrlXZ-qrPwq5N12QKuc7tCKdgkb9lU", + "dq": "pcyo73yElp3hDroqvhglG_6OyZ4uFLYL49fj1lTsmUCNlanL4w38a48hj86tYSydi4nQudvxzj_MGbeAKeXywVmAVTM7lUGeduZmdaFZwqJ2qN_5j0fNAR3H5eyrDsBzEwy_l0KvRSi4Guj2-M09sBgW2kqbbqwSi1EHG3LKi4s", + "qi": "uXITs4IC18a9lzBnGTKB75N5kWB1EVp-JhdwKnJkn44QOTWQWjAVnbgTkCuhGaPBu331FWUSxyNe6_0S3zcTWF95mlbCBT-33-ev_fHJDUNR55JeXQbJy-1Pf91W4dsA42YWEoIWzKnFrP_cwsp5Oq0AGbaO2_XwUCYTYGQpqnE" + } + } + ] +} diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 681079bb4..2fcdbff6e 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -738,6 +738,221 @@ "verify_agent_for_property": "adcp.adagents" } }, + "adcp.signing": { + "resolves": { + "ALG_ED25519": "value:'ed25519'", + "ALG_ES256": "value:'ecdsa-p256-sha256'", + "ALLOWED_ALGS": "instance:builtins:frozenset", + "AgentResolution": "adcp.signing.agent_resolver", + "AgentResolverError": "adcp.signing.agent_resolver", + "AgentResolverErrorCode": "union[value:'brand_json_resolution_failed',value:'brand_json_url_missing',value:'capabilities_invalid',value:'capabilities_unreachable',value:'invalid_agent_url',value:'jwks_fetch_failed']", + "AsyncCachingJwksResolver": "adcp.signing.jwks", + "AsyncCachingRevocationChecker": "adcp.signing.revocation_fetcher", + "AsyncIpPinnedTransport": "adcp.signing.ip_pinned_transport", + "AsyncIpPinnedTransport2": "adcp.signing.ip_pinned_transport", + "AsyncJwksFetcher": "adcp.signing.jwks", + "AsyncJwksResolver": "adcp.signing.jwks", + "AsyncRevocationListFetcher": "adcp.signing.revocation_fetcher", + "AtomicReplayStore": "adcp.signing.replay", + "BrandAgentType": "union[value:'brand',value:'buying',value:'creative',value:'governance',value:'measurement',value:'rights',value:'sales',value:'signals']", + "BrandAuthorizationReason": "union[value:'agent_ambiguous',value:'agent_not_listed',value:'agent_type_mismatch',value:'binding_failed',value:'brand_domain_invalid',value:'brand_json_unavailable',value:'etld1_match',value:'operator_delegation']", + "BrandAuthorizationResolver": "adcp.signing.brand_authz", + "BrandAuthorizationResult": "adcp.signing.brand_authz", + "BrandDomainValidationError": "adcp.signing.etld", + "BrandJsonAuthorizationResolver": "adcp.signing.brand_authz", + "BrandJsonJwksResolver": "adcp.signing.brand_jwks", + "BrandJsonResolverError": "adcp.signing.brand_jwks", + "BrandJsonResolverErrorCode": "union[value:'agent_ambiguous',value:'agent_not_found',value:'fetch_failed',value:'invalid_body',value:'invalid_house',value:'invalid_url',value:'jwks_origin_mismatch',value:'redirect_depth_exceeded',value:'redirect_loop',value:'schema_invalid']", + "BrandSourcedJwksResolver": "adcp.signing.jwks", + "CAPABILITY_OP": "value:'get_adcp_capabilities'", + "CachedCapability": "adcp.signing.capability_cache", + "CachingJwksResolver": "adcp.signing.jwks", + "CachingRevocationChecker": "adcp.signing.revocation_fetcher", + "CapabilityCache": "adcp.signing.capability_cache", + "CapabilityProvider": "union[instance:builtins:list,value:'RequestSigning | None | Awaitable[RequestSigning | None]']", + "DEFAULT_ALLOWED_PORTS": "instance:builtins:frozenset", + "DEFAULT_EXPIRES_IN_SECONDS": "value:300", + "DEFAULT_GRACE_MULTIPLIER": "value:2.0", + "DEFAULT_SKEW_SECONDS": "value:60", + "DEFAULT_TAG": "value:'adcp/request-signing/v1'", + "DirectoryResponse": "adcp.signing.wba", + "DiscoveredSignerKeys": "adcp.signing.wba", + "EvaluatedTrustRecord": "adcp.signing.trust", + "FetchResult": "adcp.signing.revocation_fetcher", + "GrantAuthorization": "adcp.signing.trust", + "GrantRequest": "adcp.signing.trust", + "InMemoryReplayStore": "adcp.signing.replay", + "InMemorySigningProvider": "adcp.signing.provider", + "IpPinnedTransport": "adcp.signing.ip_pinned_transport", + "IpPinnedTransport2": "adcp.signing.ip_pinned_transport", + "JwksResolver": "adcp.signing.jwks", + "JwksUriSignerKeys": "adcp.signing.signer_keys", + "JwsError": "adcp.signing.jws", + "JwsMalformedError": "adcp.signing.jws", + "JwsSignatureInvalidError": "adcp.signing.jws", + "JwsUnknownKeyError": "adcp.signing.jws", + "MAX_WINDOW_SECONDS": "value:300", + "NEGATIVE_CACHE_TTL_SECONDS": "value:60.0", + "NONCE_BYTES": "value:16", + "OriginBindingResolver": "adcp.signing.bindings", + "OriginBindings": "adcp.signing.bindings", + "OriginRevocationChecker": "adcp.signing.wba", + "PgReplayStore": "adcp.signing.pg.replay_store", + "REQUEST_BODY_MALFORMED": "value:'request_body_malformed'", + "REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON": "value:'request_signature_agent_not_in_brand_json'", + "REQUEST_SIGNATURE_ALG_NOT_ALLOWED": "value:'request_signature_alg_not_allowed'", + "REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS": "value:'request_signature_brand_json_ambiguous'", + "REQUEST_SIGNATURE_BRAND_JSON_MALFORMED": "value:'request_signature_brand_json_malformed'", + "REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE": "value:'request_signature_brand_json_unreachable'", + "REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING": "value:'request_signature_brand_json_url_missing'", + "REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH": "value:'request_signature_brand_origin_mismatch'", + "REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE": "value:'request_signature_capabilities_unreachable'", + "REQUEST_SIGNATURE_COMPONENTS_INCOMPLETE": "value:'request_signature_components_incomplete'", + "REQUEST_SIGNATURE_COMPONENTS_UNEXPECTED": "value:'request_signature_components_unexpected'", + "REQUEST_SIGNATURE_DIGEST_MISMATCH": "value:'request_signature_digest_mismatch'", + "REQUEST_SIGNATURE_HEADER_MALFORMED": "value:'request_signature_header_malformed'", + "REQUEST_SIGNATURE_INVALID": "value:'request_signature_invalid'", + "REQUEST_SIGNATURE_JWKS_UNAVAILABLE": "value:'request_signature_jwks_unavailable'", + "REQUEST_SIGNATURE_JWKS_UNTRUSTED": "value:'request_signature_jwks_untrusted'", + "REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH": "value:'request_signature_key_origin_mismatch'", + "REQUEST_SIGNATURE_KEY_ORIGIN_MISSING": "value:'request_signature_key_origin_missing'", + "REQUEST_SIGNATURE_KEY_PURPOSE_INVALID": "value:'request_signature_key_purpose_invalid'", + "REQUEST_SIGNATURE_KEY_REVOKED": "value:'request_signature_key_revoked'", + "REQUEST_SIGNATURE_KEY_UNKNOWN": "value:'request_signature_key_unknown'", + "REQUEST_SIGNATURE_PARAMS_INCOMPLETE": "value:'request_signature_params_incomplete'", + "REQUEST_SIGNATURE_RATE_ABUSE": "value:'request_signature_rate_abuse'", + "REQUEST_SIGNATURE_REPLAYED": "value:'request_signature_replayed'", + "REQUEST_SIGNATURE_REQUIRED": "value:'request_signature_required'", + "REQUEST_SIGNATURE_REVOCATION_STALE": "value:'request_signature_revocation_stale'", + "REQUEST_SIGNATURE_TAG_INVALID": "value:'request_signature_tag_invalid'", + "REQUEST_SIGNATURE_WINDOW_INVALID": "value:'request_signature_window_invalid'", + "REVOCATION_LIST_TYP": "value:'adcp-gov-revocation+jws'", + "ReplayClaimResult": "union[value:'capacity',value:'claimed',value:'replayed']", + "ReplayStore": "adcp.signing.replay", + "RequestBodyMalformedError": "adcp.signing.verifier", + "ResolvedSignerKey": "adcp.signing.signer_keys", + "RevocationChecker": "adcp.signing.revocation", + "RevocationDetails": "adcp.signing.revocation", + "RevocationList": "adcp.signing.revocation", + "RevocationListFetchError": "adcp.signing.revocation_fetcher", + "RevocationListFetcher": "adcp.signing.revocation_fetcher", + "RevocationListFreshnessError": "adcp.signing.revocation_fetcher", + "RevocationListParseError": "adcp.signing.revocation_fetcher", + "SIGNING_RECOVERY": "instance:builtins:mappingproxy", + "SIG_LABEL_DEFAULT": "value:'sig1'", + "SSRFValidationError": "adcp.signing.jwks", + "SignatureInputLabel": "adcp.signing.canonical", + "SignatureVerificationError": "adcp.signing.errors", + "SignedHeaders": "adcp.signing.signer", + "SignerKeyResolver": "adcp.signing.signer_keys", + "SigningAlgorithm": "union[value:'ecdsa-p256-sha256',value:'ed25519']", + "SigningConfig": "adcp.signing.autosign", + "SigningDecision": "union[value:'optional',value:'required',value:'skip']", + "SigningProfileVersion": "union[value:'3.0',value:'3.1',value:'3.2']", + "SigningProvider": "adcp.signing.provider", + "StandardWebhookError": "adcp.signing.standard_webhooks", + "StaticJwksResolver": "adcp.signing.jwks", + "StaticSignerKeys": "adcp.signing.signer_keys", + "TraceEntry": "adcp.signing.agent_resolver", + "TrustGrantEvaluator": "adcp.signing.trust", + "TrustListing": "adcp.signing.trust", + "TrustRecordAbsentError": "adcp.signing.trust", + "TrustRecordError": "adcp.signing.trust", + "TrustRecordEvaluator": "adcp.signing.trust", + "TrustRecordMatchError": "adcp.signing.trust", + "TrustRecordNotFoundError": "adcp.signing.trust", + "TrustRecordUnavailableError": "adcp.signing.trust", + "TrustResponse": "adcp.signing.trust", + "VerifiedSigner": "adcp.signing.verifier", + "VerifierCapability": "adcp.signing.verifier", + "VerifyOptions": "adcp.signing.verifier", + "WbaAuthorizationError": "adcp.signing.wba:WbaRevokedKeyError", + "WbaDiscoveryError": "adcp.signing.wba", + "WbaRevokedKeyError": "adcp.signing.wba", + "WbaSignerKeyResolver": "adcp.signing.signer_keys", + "WbaUnavailableError": "adcp.signing.wba", + "WbaVerificationError": "adcp.signing.wba", + "abuild_ip_pinned_transport": "adcp.signing.ip_pinned_transport", + "alg_for_jwk": "adcp.signing.crypto", + "as_async_resolver": "adcp.signing.jwks", + "async_default_jwks_fetcher": "adcp.signing.jwks", + "async_default_revocation_list_fetcher": "adcp.signing.revocation_fetcher", + "async_resolve_agent": "adcp.signing.agent_resolver", + "async_sign_request": "adcp.signing.signer", + "averify_detached_jws": "adcp.signing.jws", + "averify_jws_document": "adcp.signing.jws", + "b64url_decode": "adcp.signing.crypto", + "b64url_encode": "adcp.signing.crypto", + "build_async_ip_pinned_transport": "adcp.signing.ip_pinned_transport", + "build_async_ip_pinned_transport2": "adcp.signing.ip_pinned_transport", + "build_brand_json_resolvers": "adcp.signing.brand_authz", + "build_capability_cache_key": "adcp.signing.capability_cache", + "build_ip_pinned_transport": "adcp.signing.ip_pinned_transport", + "build_ip_pinned_transport2": "adcp.signing.ip_pinned_transport", + "build_signature_base": "adcp.signing.canonical", + "canonical_origin": "adcp.signing.canonical", + "canonicalize_authority": "adcp.signing.canonical", + "canonicalize_target_uri": "adcp.signing.canonical", + "check_key_origin_consistency": "adcp.signing.key_origins", + "check_wba_revocation": "adcp.signing.wba", + "compute_content_digest_sha256": "adcp.signing.digest", + "content_digest_matches": "adcp.signing.digest", + "decode_standard_webhook_secret": "adcp.signing.standard_webhooks:decode_secret", + "default_capability_cache": "instance:adcp.signing.capability_cache:CapabilityCache", + "default_jwks_fetcher": "adcp.signing.jwks", + "default_revocation_list_fetcher": "adcp.signing.revocation_fetcher", + "ensure_capability_loaded": "adcp.signing.capability_priming", + "extract_signature_bytes": "adcp.signing.crypto", + "format_signature_header": "adcp.signing.crypto", + "generate_signing_keypair": "adcp.signing.keygen", + "host_from": "adcp.signing.etld", + "install_signing_event_hook": "adcp.signing.client", + "is_development_brand_domain": "adcp.signing.etld", + "load_private_key_pem": "adcp.signing.crypto", + "malformed_authority_reason": "adcp.signing.canonical", + "operation_needs_signing": "adcp.signing.autosign", + "parse_signature_input_header": "adcp.signing.canonical", + "pem_to_adcp_jwk": "adcp.signing.keygen", + "private_key_from_jwk": "adcp.signing.crypto", + "public_directory_key": "adcp.signing.wba", + "public_key_from_jwk": "adcp.signing.crypto", + "recovery_for": "adcp.signing.errors", + "registrable_domain": "adcp.signing.etld", + "request_signature_code": "adcp.signing.agent_resolver", + "resolve_agent": "adcp.signing.agent_resolver", + "resolve_and_validate_host": "adcp.signing.jwks", + "same_development_brand_domain": "adcp.signing.etld", + "same_registrable_domain": "adcp.signing.etld", + "sign_key_directory": "adcp.signing.wba", + "sign_request": "adcp.signing.signer", + "sign_signature_base": "adcp.signing.crypto", + "sign_standard_webhook": "adcp.signing.standard_webhooks", + "sign_wba_request": "adcp.signing.wba", + "signature_challenge": "adcp.signing.errors", + "signing_operation": "adcp.signing.client", + "signing_profile_for_adcp_version": "adcp.signing.autosign", + "strict_header_precheck": "adcp.signing._header_precheck", + "supports_atomic_claim": "adcp.signing.replay", + "thumbprint_key_id": "adcp.signing.wba", + "trust_record_url": "adcp.signing.trust", + "unauthorized_response_headers": "adcp.signing.middleware", + "validate_brand_domain": "adcp.signing.etld", + "validate_jwks_uri": "adcp.signing.jwks", + "validate_resolved_ip": "adcp.signing.jwks", + "validate_trust_record": "adcp.signing.trust", + "validate_uri_static": "adcp.signing.jwks", + "verify_detached_jws": "adcp.signing.jws", + "verify_directory_response": "adcp.signing.wba", + "verify_flask_request": "adcp.signing.middleware", + "verify_from_agent_url": "adcp.signing.agent_resolver", + "verify_jws_document": "adcp.signing.jws", + "verify_request_signature": "adcp.signing.verifier", + "verify_signature": "adcp.signing.crypto", + "verify_standard_webhook": "adcp.signing.standard_webhooks", + "verify_starlette_request": "adcp.signing.middleware", + "verify_wba_request": "adcp.signing.wba" + } + }, "adcp.types": { "resolves": { "A2UiComponent": "a2ui.component", @@ -7961,5 +8176,29 @@ "Type": "trusted_match.context_match_request", "VerificationLevel": "trusted_match.identity_match_request" } + }, + "adcp.types.experimental_identity": { + "resolves": { + "AccountCapabilities": "adcp.types.experimental_identity", + "AgentOnboarding": "adcp.types._identity_preview:CoreAgentOnboarding", + "AgentOnboardingMode": "adcp.types._identity_preview:EnumsAgentOnboardingMode", + "AgentOnboardingRequirement": "adcp.types._identity_preview:CoreAgentOnboardingRequirement", + "AgentOnboardingRequirementKind": "adcp.types._identity_preview:EnumsAgentOnboardingRequirementKind", + "AgentOnboardingTier": "adcp.types._identity_preview:CoreAgentOnboardingTier", + "BuyerOnboardingPosture": "adcp.types._identity_preview:CoreBuyerOnboardingPosture", + "GetAdcpCapabilitiesResponse": "adcp.types.experimental_identity", + "GetPrincipalResponse": "adcp.types.experimental_identity", + "GrantAbsentDetails": "adcp.types.experimental_identity", + "PrincipalChangedWebhook": "adcp.types.experimental_identity", + "PrincipalCurrentResult": "adcp.types.experimental_identity", + "PrincipalRecognizedResult": "adcp.types.experimental_identity", + "PrincipalRelationship": "adcp.types._identity_preview:CorePrincipalRelationship", + "PrincipalRelationshipStatus": "adcp.types._identity_preview:EnumsPrincipalRelationshipStatus", + "PrincipalUnconfiguredResult": "adcp.types.experimental_identity", + "RequestSigning": "adcp.types.experimental_identity", + "TrustAgent": "adcp.types._identity_preview:Agent", + "TrustGrant": "adcp.types._identity_preview:Grant", + "TrustRecord": "adcp.types._identity_preview:TrustV1Trust" + } } } diff --git a/tests/test_experimental_identity.py b/tests/test_experimental_identity.py index a6dc46266..4e6840eba 100644 --- a/tests/test_experimental_identity.py +++ b/tests/test_experimental_identity.py @@ -280,15 +280,17 @@ async def test_unestablished_grant_gate_omits_oracle_details(): def test_preview_round_trips(): + from adcp.types.experimental_identity import AgentOnboardingMode, PrincipalRelationshipStatus + response = GetPrincipalResponse.model_validate( { "result": { "kind": "unconfigured", - "relationship": {"status": "pending", "setup": {"message": "Apply"}}, + "relationship": {"status": "pending_approval", "setup": {"message": "Apply"}}, } } ) - assert response.result.relationship.status == "pending" + assert response.result.relationship.status is PrincipalRelationshipStatus.pending_approval assert ( response.model_dump(mode="json", exclude_none=True)["result"]["relationship"]["setup"][ "message" @@ -299,7 +301,7 @@ def test_preview_round_trips(): supported_billing=["operator"], sandbox=True, agent_onboarding={"sandbox": {"mode": "open"}} ) assert account.agent_onboarding.sandbox.mode == "open" - assert BuyerOnboardingPosture(mode="application").mode == "application" + assert BuyerOnboardingPosture(mode="open").mode is AgentOnboardingMode.open assert GrantAbsentDetails().model_dump() == {"scope": "agent", "reason": "grant_absent"} validate_trust_record( TrustRecord.model_validate(SIGNER_RECORD).model_dump(mode="json", exclude_none=True), @@ -313,7 +315,8 @@ def test_preview_round_trips(): "roles": ["adcp:buying"], "profiles": {"adcp": {"onboarding": {"mode": "application"}}}, } - ] + ], + "profiles": {"adcp": {"onboarding": {"mode": "invite_only"}}}, }, operator_domain="platform.com", ) @@ -454,8 +457,9 @@ async def test_client_opt_in_preserves_relationship_response(monkeypatch): from adcp import ADCPClient, AgentConfig from adcp.types import GetPrincipalRequest from adcp.types.core import TaskResult, TaskStatus + from adcp.types.experimental_identity import PrincipalRelationshipStatus - payload = {"result": {"kind": "unconfigured", "relationship": {"status": "pending"}}} + payload = {"result": {"kind": "unconfigured", "relationship": {"status": "pending_approval"}}} client = ADCPClient( AgentConfig(id="seller", agent_uri="https://seller.com/mcp", protocol="mcp"), experimental_agent_identity=True, @@ -468,7 +472,7 @@ async def test_client_opt_in_preserves_relationship_response(monkeypatch): result = await client.get_principal(GetPrincipalRequest()) assert result.success assert isinstance(result.data, GetPrincipalResponse) - assert result.data.result.relationship.status == "pending" + assert result.data.result.relationship.status is PrincipalRelationshipStatus.pending_approval async def test_client_preserves_unknown_onboarding_requirements_and_signing_profiles(monkeypatch): @@ -485,7 +489,7 @@ async def test_client_preserves_unknown_onboarding_requirements_and_signing_prof "url": "https://seller.com/apply", "sandbox": { "mode": "application", - "requirements": [{"kind": "agent_identity"}, {"kind": "future_identity_badge"}], + "requirements": [{"kind": "grant"}, {"kind": "future_identity_badge"}], }, } payload = { @@ -514,7 +518,7 @@ async def test_client_preserves_unknown_onboarding_requirements_and_signing_prof 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[0].kind is AgentOnboardingRequirementKind.grant assert onboarding.sandbox.requirements[1].kind == "future_identity_badge" assert onboarding.message == posture["message"] assert str(onboarding.url) == posture["url"] @@ -554,6 +558,7 @@ async def test_trust_reader_retains_unknown_onboarding_requirements(record_level async def test_relationship_hook_does_not_create_principal(): + from adcp.server.base import ToolContext from adcp.server.principal import ( InMemoryPrincipalRecordStore, PrincipalIdentity, @@ -564,15 +569,18 @@ async def test_relationship_hook_does_not_create_principal(): async def relationship(identity): calls.append(identity.subject) - return {"status": "pending", "setup": {"message": "Apply"}} + return {"status": "pending_approval", "setup": {"message": "Apply"}} store = InMemoryPrincipalRecordStore() service = PrincipalService(store, relationship_resolver=relationship) response = await service.get_principal( - PrincipalIdentity("https://buyer.platform.com", "buyer_agent") + PrincipalIdentity("https://buyer.platform.com", "buyer_agent"), + context=ToolContext(resolved_adcp_version="3.3"), ) assert response.result.kind == "unconfigured" - assert response.model_dump(mode="json")["result"]["relationship"]["status"] == "pending" + assert ( + response.model_dump(mode="json")["result"]["relationship"]["status"] == "pending_approval" + ) assert calls == [ORIGIN] assert await store.get(ORIGIN) is None diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py new file mode 100644 index 000000000..389b5aa9e --- /dev/null +++ b/tests/test_identity_3_3_alignment.py @@ -0,0 +1,1559 @@ +"""Regressions for the final AdCP 3.3 agent-identity decisions (adcp-client-python#1500). + +Each test names the decision it pins: the signature path follows DR-0023 and +WBA §5.5/§6.10, revocation and bindings are the authorization layer, trust +outages never restore access, and the hardening found in the Go and +TypeScript reviews. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from cryptography.hazmat.primitives.asymmetric import ed25519 +from http_sfv.dictionary import Dictionary +from http_sfv.item import InnerList, Item +from starlette.requests import Request +from starlette.responses import JSONResponse + +from adcp.decisioning.registry import BuyerAgent +from adcp.decisioning.trust_grants import ( + _MEDIA_BUY_DEFAULT_OPERATIONS, + TrustGrantClaim, + TrustGrantGate, +) +from adcp.decisioning.types import Account, AdcpError +from adcp.server import ADCPHandler, RequestSignatureVerification, ToolContext +from adcp.server.signed_requests import ( + SignedRequestVerificationMiddleware, + _received_target_uri, + apply_verified_signer, + check_wba_authorization, +) +from adcp.server.translate import build_mcp_error_result +from adcp.signing import ( + InMemoryReplayStore, + OriginBindings, + StaticSignerKeys, + TrustGrantEvaluator, + TrustRecordEvaluator, + TrustRecordMatchError, + TrustRecordUnavailableError, + TrustResponse, + VerifiedSigner, + public_directory_key, + sign_request, + sign_wba_request, + trust_record_url, + verify_request_signature, +) +from adcp.signing.crypto import b64url_encode +from adcp.signing.digest import compute_content_digest_sha256 +from adcp.signing.middleware import VERIFIED_SIGNER_SCOPE_KEY +from adcp.signing.revocation_fetcher import FetchResult, RevocationListFetchError +from adcp.signing.signer_keys import ResolvedSignerKey +from adcp.signing.trust import TrustRecordError +from adcp.signing.wba import ( + DIRECTORY_MEDIA_TYPE, + DIRECTORY_PATH, + DIRECTORY_SKEW_ALLOWANCE_SECONDS, + GENERIC_AUTHENTICATION_ERROR, + DirectoryResponse, + DiscoveredSignerKeys, + OriginRevocationChecker, + WbaDiscoveryError, + WbaVerificationError, + _base, + _sign, + sign_key_directory, + verify_directory_response, + verify_wba_request, +) + +ORIGIN = "https://buyer.platform.com" +TARGET = "https://seller.example.com/mcp" + + +async def _not_revoked(origin: str, keyid: str) -> bool: + return False + + +def _tool(name: str, arguments: dict[str, Any] | None = None) -> bytes: + return json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + } + ).encode() + + +def _signed(private: Any, body: bytes, *, origin: str = ORIGIN, url: str = TARGET, **kwargs: Any): + headers = {"Content-Type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url=url, + headers=headers, + body=body, + private_key=private, + agent_origin=origin, + **kwargs, + ) + ) + return headers + + +def _member(value: str, label: str = "sig1") -> Any: + parsed = Dictionary() + parsed.parse(value.encode("ascii")) + return parsed[label] + + +def _recording_app(config: RequestSignatureVerification, *, transport: str = "mcp"): + seen: list[Any] = [] + + 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 + seen.append(scope.get(VERIFIED_SIGNER_SCOPE_KEY)) + await JSONResponse({"ok": True})(scope, receive, send) + + app = SignedRequestVerificationMiddleware( + dispatch, config, transport=transport, defer_authorization_errors=True + ) + return app, seen + + +# --------------------------------------------------------------------------- +# A1-A3: the signature path follows DR-0023 and WBA §6.10 +# --------------------------------------------------------------------------- + + +async def test_discovery_failure_never_evicts_a_cached_directory_within_max_stale(): + private = ed25519.Ed25519PrivateKey.generate() + kid = public_directory_key(private)["kid"] + now, offline, calls = [1000.0], [False], [] + + async def fetch(url, *, allow_private=False): + calls.append(url) + if offline[0]: + raise OSError("directory host is down") + return sign_key_directory( + agent_origin=ORIGIN, private_keys=[private], created=1000, expires_in_seconds=100_000 + ) + + keys = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now[0], max_stale_seconds=3600) + assert await keys(kid, signer_origin=ORIGIN) is not None + offline[0], now[0] = True, 1301 # past the five-minute refresh + assert await keys(kid, signer_origin=ORIGIN) is not None + # A key rotated in during the outage cannot be seen: a discovery failure. + with pytest.raises(WbaDiscoveryError): + await keys("n" * 43, signer_origin=ORIGIN) + now[0] = 4599.0 + assert await keys(kid, signer_origin=ORIGIN) is not None + now[0] = 4600.0 # max_stale after the last successful fetch + with pytest.raises(WbaDiscoveryError) as failure: + await keys(kid, signer_origin=ORIGIN) + assert failure.value.status == 403 and failure.value.headers["Accept-Signature"] + assert calls.count(ORIGIN + DIRECTORY_PATH) == 3 # 60 s cooldown between attempts + + +async def test_cached_directory_never_verifies_past_its_signatures(): + private = ed25519.Ed25519PrivateKey.generate() + kid = public_directory_key(private)["kid"] + now, offline = [1000.0], [False] + + async def fetch(url, *, allow_private=False): + if offline[0]: + return DirectoryResponse(b"", {}, status=503) + return sign_key_directory( + agent_origin=ORIGIN, private_keys=[private], created=900, expires_in_seconds=600 + ) + + keys = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now[0]) + assert await keys(kid, signer_origin=ORIGIN) is not None + offline[0], now[0] = True, 1499 + assert await keys(kid, signer_origin=ORIGIN) is not None + now[0] = 1500 + with pytest.raises(WbaDiscoveryError): + await keys(kid, signer_origin=ORIGIN) + + +async def test_local_discovery_capacity_is_load_shedding_not_a_challenge(): + private = ed25519.Ed25519PrivateKey.generate() + kid = public_directory_key(private)["kid"] + entered, release = asyncio.Event(), asyncio.Event() + + async def fetch(url, *, allow_private=False): + entered.set() + await asyncio.wait_for(release.wait(), timeout=5) + return sign_key_directory(agent_origin=ORIGIN, private_keys=[private], created=1000) + + keys = DiscoveredSignerKeys(fetcher=fetch, max_origins=1, clock=lambda: 1000) + pending = asyncio.create_task(keys(kid, signer_origin=ORIGIN)) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + with pytest.raises(WbaVerificationError) as refused: + await keys(kid, signer_origin="https://buyer.other.com") + assert refused.value.status == 429 + assert not refused.value.discovery_failure + assert "Accept-Signature" not in refused.value.headers + finally: + release.set() + assert await asyncio.wait_for(pending, timeout=5) is not None + + +@pytest.mark.parametrize("policy", ["reject", "anonymous"]) +@pytest.mark.parametrize("operation", ["get_products", "create_media_buy"]) +async def test_unresolvable_directory_is_unverified_under_local_policy(policy, operation): + private = ed25519.Ed25519PrivateKey.generate() + + async def fetch(url, *, allow_private=False): + return DirectoryResponse(b"", {}, status=404) + + config = RequestSignatureVerification( + signer_keys=DiscoveredSignerKeys(fetcher=fetch), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + required_for=frozenset({"create_media_buy"}), + wba_discovery_failure=policy, + ) + app, seen = _recording_app(config) + body = _tool(operation) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post(TARGET, content=body, headers=_signed(private, body)) + if policy == "anonymous" and operation == "get_products": + # Served exactly as an anonymous request: no verified signer. + assert response.status_code == 200 + assert seen == [None] + else: + # Default policy, or an operation that requires a signature. + assert response.status_code == 403 + assert response.json() == GENERIC_AUTHENTICATION_ERROR + assert "Accept-Signature" in response.headers + assert not seen + + +async def test_wba_request_to_a_seller_without_wba_is_refused_without_a_challenge(): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + config = RequestSignatureVerification(signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}})) + app, seen = _recording_app(config) + body = _tool("get_products") + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post(TARGET, content=body, headers=_signed(private, body)) + assert response.status_code == 403 + assert "Accept-Signature" not in response.headers + assert response.json() == GENERIC_AUTHENTICATION_ERROR + assert not seen + + +def test_wba_config_requires_an_origin_scoped_key_resolver(): + async def keyid_only(keyid: str) -> ResolvedSignerKey | None: + return None + + with pytest.raises(TypeError, match="signer_origin"): + RequestSignatureVerification(signer_keys=keyid_only, enable_web_bot_auth=True) + + +# --------------------------------------------------------------------------- +# A4/A5: revocation is the authorization layer +# --------------------------------------------------------------------------- + + +def _signed_revocations(private, origin, *, start, revoked=()): + from datetime import timedelta + + kid = public_directory_key(private)["kid"] + payload = { + "version": 1, + "issuer": origin, + "updated": start.isoformat(), + "next_update": (start + timedelta(seconds=300)).isoformat(), + "revoked_kids": list(revoked), + "revoked_jtis": [], + } + protected = b64url_encode( + json.dumps({"alg": "EdDSA", "kid": 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_origin_without_a_revocation_list_is_identity_established_and_unbindable(): + from datetime import datetime, timedelta, timezone + + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + start = datetime.now(timezone.utc) + now, published = [0.0], [False] + + async def resolve(keyid, *, signer_origin=None): + return ResolvedSignerKey(signer_origin, jwk) + + async def fetch(uri, **kwargs): + if not published[0]: + raise RevocationListFetchError("not published", status_code=404) + return _signed_revocations(private, ORIGIN, start=start) + + checker = OriginRevocationChecker( + resolve, + fetcher=fetch, + clock=lambda: now[0], + wall_clock=lambda: start + timedelta(seconds=now[0]), + ) + assert await checker.status(ORIGIN, jwk["kid"]) == "no_list" + + async def listed(url, *, allow_private=False): + return TrustResponse( + json.dumps({"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}).encode() + ) + + records = TrustRecordEvaluator(fetcher=listed) + signer = VerifiedSigner( + jwk["kid"], "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + ) + for state in (None, "no_list", "stale", "unavailable"): + with pytest.raises(TrustRecordError, match="revocation list"): + await records.bind( + VerifiedSigner(**{**signer.__dict__, "revocation": state}), + principal_id="p", + bindings=OriginBindings(), + ) + published[0], now[0] = True, 61 + assert await checker.status(ORIGIN, jwk["kid"]) == "current" + # Once a list was verified, a later 404 is a failure to read it, never a + # reset to "no list": past next_update plus grace the origin is stale. + published[0], now[0] = False, 61 + 300 + 4 * 300 + assert await checker.status(ORIGIN, jwk["kid"]) == "stale" + + +@pytest.mark.parametrize("revocation", ["no_list", "current", "stale", "unavailable"]) +@pytest.mark.parametrize("operation", ["get_media_buys", "create_media_buy"]) +async def test_binding_gate_reads_the_revocation_state(revocation, operation): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + + class Checker: + async def status(self, origin, keyid): + return revocation + + async def listed(url, *, allow_private=False): + return TrustResponse( + json.dumps({"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}).encode() + ) + + bindings = OriginBindings() + bindings.bind(ORIGIN, "principal-1") + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + enable_web_bot_auth=True, + wba_revocation_checker=Checker(), + origin_bindings=bindings, + trust_records=TrustRecordEvaluator(fetcher=listed), + unbound_for=frozenset({"get_media_buys", "create_media_buy"}), + ) + app, seen = _recording_app(config) + body = _tool(operation) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post(TARGET, content=body, headers=_signed(private, body)) + assert response.status_code == 200, response.text + assert "Accept-Signature" not in response.headers + outage = revocation == "unavailable" or ( + revocation == "stale" and operation == "create_media_buy" + ) + if outage: + error = response.json()["structuredContent"]["adcp_error"] + assert error["code"] == "SERVICE_UNAVAILABLE" + assert not seen + return + (signer,) = seen + assert signer.revocation == revocation + # No revocation list: identity-established, never acting for the principal. + assert signer.principal_id == (None if revocation == "no_list" else "principal-1") + + +async def test_revoked_relay_key_is_refused_like_the_originator(): + from adcp.signing.wba import WbaRevokedKeyError, check_wba_revocation + + originator, relay = ( + VerifiedSigner( + kid * 43, "ed25519", label, time.time(), agent_url=origin, profile="web-bot-auth" + ) + for kid, label, origin in ( + ("o", "sig1", ORIGIN), + ("r", "sig2", "https://relay.example.com"), + ) + ) + + class Checker: + async def status(self, origin, keyid): + return "revoked" if keyid == relay.key_id else "current" + + assert (await check_wba_revocation(Checker(), (originator,)))[0].revocation == "current" + with pytest.raises(WbaRevokedKeyError) as revoked: + await check_wba_revocation(Checker(), (originator, relay)) + assert revoked.value.status == 403 + assert revoked.value.label == "sig2" + assert not revoked.value.headers + + +# --------------------------------------------------------------------------- +# A6: trust-record outages +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bound,at,allowed", [(30, 1089, True), (30, 1090, False), (0, 1061, False)] +) +async def test_outage_continuation_is_seller_policy(bound, at, 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], outage_continuation_seconds=bound + ) + assert (await records.check_listing(ORIGIN, role="adcp:buying")).status == "listed" + offline[0], now[0] = True, at + listing = await records.check_listing(ORIGIN, role="adcp:buying") + assert listing.status == "unavailable" + assert listing.within_grace is allowed + with pytest.raises(ValueError): + TrustRecordEvaluator(outage_continuation_seconds=-1) + + +async def test_operators_retire_agents_with_an_empty_agents_list(): + now, retired = [1000.0], [False] + + async def fetch(url, *, allow_private=False): + agents = [] if retired[0] else [{"url": ORIGIN, "roles": ["adcp:buying"]}] + return TrustResponse(json.dumps({"agents": agents}).encode()) + + records = TrustRecordEvaluator(fetcher=fetch, clock=lambda: now[0]) + assert (await records.check_listing(ORIGIN, role="adcp:buying")).status == "listed" + retired[0], now[0] = True, 1061 + listing = await records.check_listing(ORIGIN, role="adcp:buying") + assert listing.status == "unlisted" + assert isinstance(listing.error, TrustRecordMatchError) + + +async def test_operator_that_never_published_is_unlisted_not_an_outage(): + async def fetch(url, *, allow_private=False): + return TrustResponse(b"", 404) + + listing = await TrustRecordEvaluator(fetcher=fetch).check_listing(ORIGIN, role="adcp:buying") + assert listing.status == "unlisted" + + +async def test_grantor_outage_without_a_prior_read_is_service_unavailable(): + async def fetch(url, *, allow_private=False): + if url == trust_record_url(ORIGIN): + return TrustResponse( + json.dumps({"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}).encode() + ) + raise OSError("brand record unreachable") + + result = await TrustGrantEvaluator(TrustRecordEvaluator(fetcher=fetch)).check( + agent_origin=ORIGIN, + brand_domain="brand.com", + account_operator="platform.com", + allow_stale_on_outage=True, + ) + assert not result.authorized and result.error_code == "SERVICE_UNAVAILABLE" + + +# --------------------------------------------------------------------------- +# A9: grants (L2 §Grants in trust.json) +# --------------------------------------------------------------------------- + + +def _grant_records(documents: dict[str, Any], fetched: list[str] | None = None): + async def fetch(url, *, allow_private=False): + if fetched is not None: + fetched.append(url) + document = documents.get(url) + if isinstance(document, Exception): + raise document + if document is None: + return TrustResponse(b"", 404) + return TrustResponse(json.dumps(document).encode()) + + return TrustRecordEvaluator(fetcher=fetch, clock=lambda: 1000) + + +async def test_only_the_house_record_authorizes(): + fetched: list[str] = [] + documents = { + trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, + # The brand's own record must never be consulted once it names a house. + trust_record_url("brand.com"): { + "grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}] + }, + trust_record_url("house.com"): { + "grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}] + }, + } + + async def house(brand_domain: str, brand_id: str | None) -> str | None: + return "house.com" if brand_domain == "brand.com" else None + + records = _grant_records(documents, fetched) + result = await TrustGrantEvaluator(records, resolve_house=house).check( + agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" + ) + assert result.authorized + assert trust_record_url("brand.com") not in fetched + documents[trust_record_url("house.com")] = {"grants": []} + denied = await TrustGrantEvaluator(_grant_records(documents), resolve_house=house).check( + agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" + ) + assert not denied.authorized and denied.reason == "grant_absent" + + async def broken(brand_domain: str, brand_id: str | None) -> str | None: + raise OSError("brand.json unreachable") + + outage = await TrustGrantEvaluator(_grant_records(documents), resolve_house=broken).check( + agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" + ) + assert not outage.authorized and outage.error_code == "SERVICE_UNAVAILABLE" + + +@pytest.mark.parametrize( + "first,second,authorized", + [ + ({}, {}, True), + # The window is the latest valid_from to the earliest valid_until. + ({"valid_from": "1970-01-01T00:16:00Z"}, {"valid_until": "1970-01-01T00:16:50Z"}, True), + ({"valid_from": "1970-01-01T00:16:50Z"}, {}, False), + ({}, {"valid_until": "1970-01-01T00:16:40Z"}, False), + # A narrowed first link ends the chain unless it lists the signer. + ({"agents": [{"url": "https://buyer.agency.com"}]}, {}, False), + ({"agents": [{"url": ORIGIN}]}, {}, True), + ({}, {"agents": [{"url": "https://other.platform.com"}]}, False), + ], +) +async def test_two_link_chain_narrowing_and_window_intersection(first, second, authorized): + documents = { + trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, + trust_record_url("brand.com"): { + "grants": [{"grantee": "agency.com", "scopes": ["adcp:media_buying"], **first}] + }, + trust_record_url("agency.com"): { + "grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"], **second}] + }, + } + result = await TrustGrantEvaluator(_grant_records(documents)).check( + agent_origin=ORIGIN, brand_domain="brand.com", account_operator="agency.com" + ) + assert result.authorized is authorized + if authorized and "valid_until" in second: + assert result.expires_at == 1010 + + +async def test_grant_domains_compare_as_registrable_domains(): + documents = { + trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, + trust_record_url("brand.com"): { + "grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}] + }, + } + result = await TrustGrantEvaluator(_grant_records(documents)).check( + agent_origin=ORIGIN, + brand_domain="https://WWW.Brand.com/", + account_operator="ads.platform.com", + ) + assert result.authorized + + +# --------------------------------------------------------------------------- +# B: interop details not covered by the vendored fixtures +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "nonce,accepted", + [ + (b64url_encode(b"\x07" * 15), False), # 20 characters + (b64url_encode(b"\x07" * 16), True), # 22 characters, the 128-bit floor + (b64url_encode(b"\x07" * 32), True), + (b64url_encode(b"\x07" * 64), True), + ("A" * 256, True), + ("A" * 257, False), + ("A" * 21, False), + ], +) +async def test_verifier_accepts_22_to_256_character_nonces(nonce, accepted): + """64 bytes is RECOMMENDED for signers; a compliant signer may send fewer.""" + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + body = _tool("get_products") + if not accepted: + with pytest.raises(ValueError, match="128 bits"): + _signed(private, body, created=1000, nonce=nonce) + valid = "B" * 86 + headers = _signed(private, body, created=1000, nonce=valid) + headers["Signature-Input"] = headers["Signature-Input"].replace(valid, nonce) + else: + headers = _signed(private, body, created=1000, nonce=nonce) + options = { + "method": "POST", + "url": TARGET, + "headers": headers, + "body": body, + "signer_keys": StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + "replay_store": InMemoryReplayStore(), + "now": 1001, + } + if accepted: + (signer,) = await verify_wba_request(**options) + assert signer.agent_url == ORIGIN + else: + with pytest.raises(WbaVerificationError, match="nonce"): + await verify_wba_request(**options) + + +def test_signer_defaults_to_a_64_byte_nonce(): + from adcp.signing.wba import is_64_byte_nonce + + headers = _signed(ed25519.Ed25519PrivateKey.generate(), _tool("get_products")) + assert is_64_byte_nonce(_member(headers["Signature-Input"]).params["nonce"]) + + +def test_received_target_uri_keeps_percent_encoding_and_query(): + scope = { + "scheme": "https", + "path": "/a~b/mcp/", + "raw_path": b"/a%7Eb/mcp/", + "query_string": b"x=%41", + "headers": [(b"host", b"seller.example.com")], + } + assert _received_target_uri(scope) == "https://seller.example.com/a%7Eb/mcp/?x=%41" + + +async def test_middleware_verifies_a_percent_encoded_target_as_received(): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + ) + app, seen = _recording_app(config) + url = "https://seller.example.com/a%7Eb/mcp/" + body = _tool("get_principal") + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post(url, content=body, headers=_signed(private, body, url=url)) + assert response.status_code == 200, response.text + assert seen[0].agent_url == ORIGIN + + +def _unsigned_directory(*jwks: dict[str, Any], media_type: str = "application/json"): + body = json.dumps({"keys": list(jwks)}).encode() + return DirectoryResponse(body, {"Content-Type": media_type}) + + +def test_plain_jwks_directory_is_accepted_by_thumbprint_unless_signatures_are_required(): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + without_kid = {k: v for k, v in jwk.items() if k != "kid"} + other_use = {"kty": "oct", "k": "c2VjcmV0"} # not a key the profile can use + keys = verify_directory_response( + ORIGIN, _unsigned_directory(without_kid, {**jwk, "use": "enc"}), now=1000 + ) + assert list(keys) == [jwk["kid"]] + for media_type in ("application/jwk-set+json", DIRECTORY_MEDIA_TYPE): + assert verify_directory_response( + ORIGIN, _unsigned_directory(without_kid, media_type=media_type), now=1000 + ) + with pytest.raises(WbaVerificationError, match="private key material"): + verify_directory_response(ORIGIN, _unsigned_directory(without_kid, other_use), now=1000) + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response( + ORIGIN, _unsigned_directory(without_kid), now=1000, require_signed=True + ) + + +def _partly_signed_directory(signed_key, unsigned_key, *, components=None, created=1000): + jwks = [public_directory_key(signed_key), public_directory_key(unsigned_key)] + body = json.dumps({"keys": jwks}).encode() + headers = { + "Content-Type": DIRECTORY_MEDIA_TYPE, + "Content-Digest": compute_content_digest_sha256(body), + } + authority = Item("@authority") + authority.params["req"] = True + member = InnerList(components or [authority, Item("content-digest")]) + member.params.update( + { + "created": created, + "expires": created + 3600, + "keyid": jwks[0]["kid"], + "alg": "ed25519", + "tag": "http-message-signatures-directory", + } + ) + base = _base("GET", ORIGIN + DIRECTORY_PATH, headers, member, directory_response=True) + headers["Signature-Input"] = str(Dictionary({"key1": member})) + headers["Signature"] = str(Dictionary({"key1": Item(_sign("ed25519", signed_key, base))})) + return DirectoryResponse(body, headers), jwks + + +def test_signed_directory_indexes_only_keys_with_a_verified_signature(): + signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) + response, jwks = _partly_signed_directory(signed, unsigned) + keys = verify_directory_response(ORIGIN, response, now=1001) + assert list(keys) == [jwks[0]["kid"]] + + +def test_directory_signatures_require_the_exact_component_identifiers(): + signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) + keyed_digest = Item("content-digest") + keyed_digest.params["key"] = "sha-256" + bare_authority = Item("@authority") + for components in ([Item("@authority"), Item("content-digest")], [bare_authority]): + with pytest.raises(WbaVerificationError): + verify_directory_response( + ORIGIN, + _partly_signed_directory(signed, unsigned, components=components)[0], + now=1001, + ) + authority = Item("@authority") + authority.params["req"] = True + with pytest.raises(WbaVerificationError): + verify_directory_response( + ORIGIN, + _partly_signed_directory(signed, unsigned, components=[authority, keyed_digest])[0], + now=1001, + ) + + +def test_directory_digest_covers_the_exact_bytes_including_a_bom(): + from dataclasses import replace + + response = sign_key_directory( + agent_origin=ORIGIN, private_keys=[ed25519.Ed25519PrivateKey.generate()], created=1000 + ) + with pytest.raises(WbaVerificationError, match="digest"): + verify_directory_response( + ORIGIN, replace(response, body=b"\xef\xbb\xbf" + response.body), now=1001 + ) + + +def test_directory_created_is_backdated_and_tolerates_a_verifier_behind(): + before = int(time.time()) + response = sign_key_directory( + agent_origin=ORIGIN, private_keys=[ed25519.Ed25519PrivateKey.generate()] + ) + member = _member(response.headers["Signature-Input"], "key1") + assert member.params["created"] <= before - DIRECTORY_SKEW_ALLOWANCE_SECONDS + 1 + assert member.params["expires"] >= before + 86400 + assert verify_directory_response(ORIGIN, response, now=time.time() - 30) + + +async def test_a_synchronously_throwing_fetcher_cannot_poison_discovery(): + private = ed25519.Ed25519PrivateKey.generate() + kid = public_directory_key(private)["kid"] + now, broken = [1000.0], [True] + + def fetch(url, *, allow_private=False): + if broken[0]: + raise RuntimeError("synchronous failure") + + async def served(): + return sign_key_directory(agent_origin=ORIGIN, private_keys=[private], created=1000) + + return served() + + keys = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now[0]) + with pytest.raises(WbaDiscoveryError): + await keys(kid, signer_origin=ORIGIN) + assert not keys._pending + broken[0], now[0] = False, 1061 + assert await keys(kid, signer_origin=ORIGIN) is not None + + +async def test_wba_replay_at_exactly_expires_plus_skew_is_rejected(): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + body = _tool("get_products") + headers = _signed(private, body, created=1000, expires_in_seconds=300) + clock = [1000.0] + store = InMemoryReplayStore(clock=lambda: clock[0]) + options = { + "method": "POST", + "url": TARGET, + "headers": headers, + "body": body, + "signer_keys": StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + "replay_store": store, + } + await verify_wba_request(**options, now=1000) + clock[0] = 1360.0 + with pytest.raises(WbaVerificationError, match="replayed"): + await verify_wba_request(**options, now=1360) + + +def test_rfc9421_replay_at_exactly_expires_plus_skew_is_rejected(): + from adcp.signing import ( + SignatureVerificationError, + VerifierCapability, + VerifyOptions, + generate_signing_keypair, + load_private_key_pem, + ) + + pem, jwk = generate_signing_keypair(kid="replay-key") + body = b"{}" + signed = sign_request( + method="POST", + url=TARGET, + headers={"Content-Type": "application/json"}, + body=body, + private_key=load_private_key_pem(pem), + key_id="replay-key", + alg="ed25519", + created=1000, + expires_in_seconds=300, + signing_profile_version="3.2", + ) + clock = [1000.0] + store = InMemoryReplayStore(clock=lambda: clock[0]) + + def verify(now: float) -> None: + verify_request_signature( + method="POST", + url=TARGET, + headers={"Content-Type": "application/json", **signed.as_dict()}, + body=body, + options=VerifyOptions( + now=now, + capability=VerifierCapability(), + operation="get_products", + jwks_resolver=lambda keyid: jwk, + replay_store=store, + signing_profile_version="3.2", + ), + ) + + verify(1000) + clock[0] = 1360.0 + with pytest.raises(SignatureVerificationError, match="replay"): + verify(1360) + + +async def test_signer_never_strips_another_partys_signature_fields(): + import httpx as _httpx + + from adcp.signing import SigningConfig + from adcp.signing.client import install_signing_event_hook, signing_operation + from adcp.types.experimental_identity import RequestSigning + + private = ed25519.Ed25519PrivateKey.generate() + existing = {"Signature-Input": 'sig0=("@method");created=1', "Signature": "sig0=:AAAA:"} + with pytest.raises(ValueError, match="another party"): + sign_wba_request( + method="POST", + url=TARGET, + headers={"Content-Type": "application/json", **existing}, + body=b"{}", + private_key=private, + agent_origin=ORIGIN, + ) + config = SigningConfig( + private_key=private, + key_id=public_directory_key(private)["kid"], + agent_origin=ORIGIN, + ) + sent: list[_httpx.Request] = [] + client = _httpx.AsyncClient( + transport=_httpx.MockTransport(lambda request: sent.append(request) or _httpx.Response(200)) + ) + install_signing_event_hook( + client, + signing=config, + seller_capability=RequestSigning( + supported=True, required_for=["get_products"], profiles=["web-bot-auth"] + ), + ) + with signing_operation("get_products"), pytest.raises(ValueError, match="another party"): + await client.post(TARGET, content=b"{}", headers={**existing, "Content-Type": "x/y"}) + assert not sent + await client.aclose() + + +# --------------------------------------------------------------------------- +# C: trust host budgets and history quotas +# --------------------------------------------------------------------------- + + +async def test_authoritative_location_hops_never_spend_the_victims_own_budget(): + entered, release = asyncio.Event(), asyncio.Event() + hop_calls = [] + + async def fetch(url, *, allow_private=False): + host = url.split("/")[2] + if host.startswith("attacker-"): + index = host.split(".")[0] + return TrustResponse( + json.dumps({"authoritative_location": f"https://victim.com/{index}.json"}).encode() + ) + if url != "https://victim.com/.well-known/trust.json": + hop_calls.append(url) + if len(hop_calls) == 2: + entered.set() + await asyncio.wait_for(release.wait(), timeout=5) + return TrustResponse(b'{"agents":[]}') + return TrustResponse( + json.dumps( + {"agents": [{"url": "https://buyer.victim.com", "roles": ["adcp:buying"]}]} + ).encode() + ) + + records = TrustRecordEvaluator( + fetcher=fetch, max_fetches_per_host=1, max_foreign_fetches_per_host=2 + ) + attackers = [ + asyncio.create_task(records.evaluate(f"https://attacker-{index}.com")) for index in range(2) + ] + try: + await asyncio.wait_for(entered.wait(), timeout=5) + # The foreign budget for victim.com is full; another hop is refused... + with pytest.raises(TrustRecordUnavailableError, match="capacity"): + await records.evaluate("https://attacker-2.com") + # ...but the victim's own fetch of its own host still proceeds. + victim = await asyncio.wait_for(records.evaluate("https://buyer.victim.com"), 5) + assert victim.operator_domain == "victim.com" + finally: + release.set() + await asyncio.wait_for(asyncio.gather(*attackers, return_exceptions=True), timeout=5) + assert not records._foreign_inflight and not records._hosts_inflight + + +async def test_revocation_admission_never_locks_new_origins_out(): + """Checker state is an LRU cache; revoked facts survive its eviction.""" + from datetime import datetime, timezone + + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + start = datetime.now(timezone.utc) + fetches: list[str] = [] + + async def resolve(keyid, *, signer_origin=None): + return ResolvedSignerKey(signer_origin, jwk) + + async def fetch(uri, **kwargs): + origin = uri.partition("/.well-known")[0] + fetches.append(origin) + revoked = [jwk["kid"]] if origin == "https://a.vercel.app" else [] + return _signed_revocations(private, origin, start=start, revoked=revoked) + + checker = OriginRevocationChecker( + resolve, fetcher=fetch, max_origins=3, max_origins_per_operator=2 + ) + assert await checker.status("https://a.vercel.app", jwk["kid"]) == "revoked" + assert await checker.status("https://b.vercel.app", jwk["kid"]) == "current" + # A third free tenant of the same provider shares the quota group: it + # evicts the least recently used tenant's list state, never locks out. + assert await checker.status("https://c.vercel.app", jwk["kid"]) == "current" + assert "https://a.vercel.app" not in checker._checkers + # An attacker flood across operators cannot lock a new buyer out either. + for index in range(5): + status = await checker.status(f"https://buyer-{index}.example{index}.com", jwk["kid"]) + assert status == "current" + assert len(checker._checkers) <= 3 + # The evicted origin's revocation was retained, without a refetch. + before = len(fetches) + assert await checker.status("https://a.vercel.app", jwk["kid"]) == "revoked" + assert len(fetches) == before + + +async def test_concurrent_revocation_discovery_overflow_is_unavailable(): + from datetime import datetime, timezone + + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + start = datetime.now(timezone.utc) + entered, release = asyncio.Event(), asyncio.Event() + + async def resolve(keyid, *, signer_origin=None): + return ResolvedSignerKey(signer_origin, jwk) + + async def fetch(uri, **kwargs): + entered.set() + await asyncio.wait_for(release.wait(), timeout=5) + return _signed_revocations(private, uri.partition("/.well-known")[0], start=start) + + checker = OriginRevocationChecker(resolve, fetcher=fetch, max_origins=1) + pending = asyncio.create_task(checker.status(ORIGIN, jwk["kid"])) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + # Nothing evictable while a discovery is in flight: fail every bound + # call closed rather than letting a revoked key act as "stale". + assert await checker.status("https://buyer.other.com", jwk["kid"]) == "unavailable" + finally: + release.set() + assert await asyncio.wait_for(pending, timeout=5) == "current" + + +# --------------------------------------------------------------------------- +# C: grant gate +# --------------------------------------------------------------------------- + +BUYER = BuyerAgent(agent_url=ORIGIN, display_name="Buyer", status="active") + + +def _gate(extract: Any) -> TrustGrantGate: + documents = { + trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, + trust_record_url("brand.com"): { + "grants": [{"grantee": "platform.com", "scopes": ["adcp:media_buying"]}] + }, + } + return TrustGrantGate(TrustGrantEvaluator(_grant_records(documents)), extract) + + +def test_list_products_uses_the_media_buying_scope(): + assert "list_products" in _MEDIA_BUY_DEFAULT_OPERATIONS + + +@pytest.mark.parametrize("failure", ["raises", "empty_brand", "empty_operator", "not_a_claim"]) +async def test_account_without_a_resolvable_claim_fails_closed(failure): + def extract(account: Account[Any], agent: BuyerAgent) -> Any: + if failure == "raises": + return account.metadata["brand_domain"] # KeyError: missing seller state + if failure == "not_a_claim": + return {"brand_domain": "brand.com"} + return TrustGrantClaim( + "" if failure == "empty_brand" else "brand.com", + "" if failure == "empty_operator" else "platform.com", + ) + + with pytest.raises(AdcpError) as denied: + await _gate(extract).require_account(Account(id="a"), BUYER, operation="get_products") + assert denied.value.code == "PERMISSION_DENIED" + await _gate( + lambda account, agent: TrustGrantClaim("brand.com", "platform.com") + ).require_account(Account(id="a"), BUYER, operation="list_products") + + +async def test_sync_accounts_checks_the_existing_account_behind_an_account_id(): + from concurrent.futures import ThreadPoolExecutor + + from adcp.decisioning import ( + AuthInfo, + DecisioningCapabilities, + DecisioningPlatform, + HttpSigCredential, + InMemoryTaskRegistry, + SingletonAccounts, + signing_only_registry, + ) + from adcp.decisioning.handler import PlatformHandler + from adcp.types import SyncAccountsRequest + + upserted: list[Any] = [] + resolved: list[Any] = [] + + class Accounts(SingletonAccounts): + def resolve(self, ref: Any = None, auth_info: Any = None) -> Any: + resolved.append(ref) + if ref and ref.get("account_id") == "victim": + return Account( + id="victim", + metadata={"claim": TrustGrantClaim("victim-brand.com", "victim.com")}, + ) + return None + + def upsert_request(self, request: Any, ctx: Any = None) -> Any: + upserted.append(request) + return {"accounts": []} + + class Platform(DecisioningPlatform): + capabilities = DecisioningCapabilities() + accounts = Accounts(account_id="victim") + + 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": "p", + "wba_revocation": "current", + }, + ) + } + ) + gate = _gate(lambda account, agent: account.metadata["claim"]) + 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, + ) + # A natural key the signer is granted cannot carry an update to an + # existing account it is not granted. + request = SyncAccountsRequest.model_validate( + { + "accounts": [ + { + "brand": {"domain": "brand.com"}, + "operator": "platform.com", + "billing": "operator", + } + ], + "idempotency_key": "k" * 16, + } + ) + rows = [ + { + "account_id": "victim", + "brand": {"domain": "brand.com"}, + "operator": "platform.com", + } + ] + object.__setattr__(request, "accounts", rows) + with pytest.raises(AdcpError) as denied: + await handler.sync_accounts(request, context) + assert denied.value.code == "PERMISSION_DENIED" + assert not upserted + # An account_id that does not resolve is denied before any upsert. + object.__setattr__(request, "accounts", [{"account_id": "missing"}]) + with pytest.raises(AdcpError): + await handler.sync_accounts(request, context) + assert not upserted + + +# --------------------------------------------------------------------------- +# C: stateful MCP sessions read the verified signer per request +# --------------------------------------------------------------------------- + + +async def test_stateful_mcp_session_reads_each_requests_own_wba_signer(): + from adcp.server.serve import _build_mcp_and_a2a_app + + origins = ("https://buyer-one.platform.com", "https://buyer-two.platform.com") + privates = {origin: ed25519.Ed25519PrivateKey.generate() for origin in origins} + bindings = OriginBindings() + for index, origin in enumerate(origins): + bindings.bind(origin, f"principal-{index}") + + async def listed(url, *, allow_private=False): + return TrustResponse( + json.dumps( + {"agents": [{"url": origin, "roles": ["adcp:buying"]} for origin in origins]} + ).encode() + ) + + seen: list[tuple[str | None, str | None]] = [] + + class Handler(ADCPHandler[Any]): + async def get_media_buys(self, params: Any, context: Any = None) -> dict[str, Any]: + auth = context.metadata["adcp.auth_info"] + seen.append((context.caller_identity, auth.extra["principal_id"])) + return {"media_buys": []} + + app = _build_mcp_and_a2a_app( + Handler(), + name="stateful-wba", + port=0, + host="127.0.0.1", + instructions=None, + test_controller=None, + validation=None, + advertise_all=True, + stateless_http=False, + allowed_hosts=["localhost"], + request_signature_verification=RequestSignatureVerification( + signer_keys=StaticSignerKeys( + {origin: {"keys": [public_directory_key(key)]} for origin, key in privates.items()} + ), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + origin_bindings=bindings, + trust_records=TrustRecordEvaluator(fetcher=listed), + ), + ) + mcp_headers = {"accept": "application/json, text/event-stream"} + + def signed(origin: str, payload: dict[str, Any], extra: dict[str, str]): + body = json.dumps(payload).encode() + headers = {**mcp_headers, **extra, "content-type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url="http://localhost/mcp", + headers=headers, + body=body, + private_key=privates[origin], + agent_origin=origin, + ) + ) + return body, headers + + initialize = { + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "t", "version": "1"}, + }, + } + async with LifespanManager(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost" + ) as client: + body, headers = signed(origins[0], initialize, {}) + init = await client.post("/mcp", content=body, headers=headers) + assert init.status_code == 200, init.text + session = {"mcp-session-id": init.headers["mcp-session-id"]} + for index, origin in enumerate((origins[1], origins[0], origins[1])): + call = { + "jsonrpc": "2.0", + "id": index + 1, + "method": "tools/call", + "params": {"name": "get_media_buys", "arguments": {}}, + } + body, headers = signed(origin, call, session) + response = await client.post("/mcp", content=body, headers=headers) + assert response.status_code == 200, response.text + assert seen == [ + (origins[1], "principal-1"), + (origins[0], "principal-0"), + (origins[1], "principal-1"), + ] + + +# --------------------------------------------------------------------------- +# Protocol review follow-ups +# --------------------------------------------------------------------------- + + +async def test_house_brand_id_is_checked_against_the_house_portfolio(): + from adcp.signing.trust import BrandHouse + + documents = { + trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, + trust_record_url("house.com"): { + "grants": [ + { + "grantee": "platform.com", + "scopes": ["adcp:media_buying"], + "profiles": {"adcp": {"brands": ["nova_motors"]}}, + } + ] + }, + } + seen: list[tuple[str, str | None]] = [] + + async def house(brand_domain: str, brand_id: str | None) -> BrandHouse: + seen.append((brand_domain, brand_id)) + return BrandHouse("house.com", brand_id="nova_motors") + + result = await TrustGrantEvaluator(_grant_records(documents), resolve_house=house).check( + agent_origin=ORIGIN, + brand_domain="novamotors.com", + account_operator="platform.com", + brand_id="own_brand_id", + ) + assert result.authorized + assert seen == [("novamotors.com", "own_brand_id")] + + +async def test_grantee_compares_as_a_registrable_domain(): + documents = { + trust_record_url(ORIGIN): {"agents": [{"url": ORIGIN, "roles": ["adcp:buying"]}]}, + trust_record_url("brand.com"): { + "grants": [{"grantee": "ads.platform.com", "scopes": ["adcp:media_buying"]}] + }, + } + result = await TrustGrantEvaluator(_grant_records(documents)).check( + agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" + ) + assert result.authorized + + +@pytest.mark.parametrize( + "operation,params,spend", + [ + ("update_media_buy", {"name": "label"}, False), + ("update_media_buy", {"paused": True}, False), + ("control_media_buy", {"canceled": True, "cancellation_reason": "done"}, False), + ("update_media_buy", {"packages": [{"package_id": "p", "paused": True}]}, False), + ("update_media_buy", {"end_time": "2027-01-01T00:00:00Z"}, True), + ("update_media_buy", {"start_time": "2026-01-01T00:00:00Z"}, True), + ("update_media_buy", {"pacing": "asap"}, True), + ("update_media_buy", {"invoice_recipient": {}}, True), + ("update_media_buy", {"packages": [{"package_id": "p", "impressions": 10}]}, True), + ("update_media_buy", {"packages": [{"package_id": "p", "bid_price": 2}]}, True), + ("update_media_buy", {"packages": [{"package_id": "p", "end_time": "x"}]}, True), + ("control_media_buy", {"packages": [{"package_id": "p", "min_spend_target": 1}]}, True), + ("update_media_buy", {"paused": False}, True), + ("update_media_buy", {"future_field": 1}, True), + ("update_media_buy", None, True), + ("activate_signal", {}, True), + ], +) +def test_spend_classification_fails_closed(operation, params, spend): + from adcp.server.signed_requests import is_wba_spend_committing + + assert is_wba_spend_committing(operation, params) is spend + + +@pytest.mark.parametrize("rfc9421_profile", [True, False]) +async def test_unsigned_required_request_gets_the_wba_challenge(rfc9421_profile): + private = ed25519.Ed25519PrivateKey.generate() + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [public_directory_key(private)]}}), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + required_for=frozenset({"create_media_buy"}), + rfc9421_profile=rfc9421_profile, + ) + app, seen = _recording_app(config) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post( + TARGET, content=_tool("create_media_buy"), headers={"Content-Type": "application/json"} + ) + assert not seen + assert 'tag="web-bot-auth"' in response.headers["Accept-Signature"] + if rfc9421_profile: + # Both profiles advertised: answered as the RFC 9421 profile does. + assert response.status_code == 401 + assert "request_signature_required" in response.headers["WWW-Authenticate"] + else: + assert response.status_code == 403 + assert response.json() == GENERIC_AUTHENTICATION_ERROR + + +async def test_wba_only_verifier_refuses_rfc9421_signatures_with_the_wba_challenge(): + from adcp.signing import generate_signing_keypair, load_private_key_pem + + pem, jwk = generate_signing_keypair(kid="legacy-key") + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + rfc9421_profile=False, + ) + app, seen = _recording_app(config) + body = _tool("get_products") + signed = sign_request( + method="POST", + url=TARGET, + headers={"Content-Type": "application/json"}, + body=body, + private_key=load_private_key_pem(pem), + key_id="legacy-key", + alg="ed25519", + signing_profile_version="3.2", + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post( + TARGET, + content=body, + headers={"Content-Type": "application/json", **signed.as_dict()}, + ) + assert response.status_code == 403 + assert "Accept-Signature" in response.headers + assert not seen + + +def test_capability_profiles_select_the_offered_profiles(): + keys = DiscoveredSignerKeys() + both = RequestSignatureVerification.from_capability( + {"profiles": ["adcp/request-signing/v1", "web-bot-auth"]}, signer_keys=keys + ) + assert both.enable_web_bot_auth and both.rfc9421_profile + only = RequestSignatureVerification.from_capability( + {"profiles": ["web-bot-auth"]}, signer_keys=keys + ) + assert only.enable_web_bot_auth and not only.rfc9421_profile + + +@pytest.mark.parametrize("host", ["seller.example.com", "other-seller.example.com"]) +async def test_authorities_refuse_a_signature_captured_for_another_host(host): + private = ed25519.Ed25519PrivateKey.generate() + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [public_directory_key(private)]}}), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + authorities=frozenset({"seller.example.com"}), + ) + app, seen = _recording_app(config) + url = f"https://{host}/mcp" + body = _tool("get_principal") + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post(url, content=body, headers=_signed(private, body, url=url)) + if host == "seller.example.com": + assert response.status_code == 200, response.text + else: + assert response.status_code == 403 + assert response.json() == GENERIC_AUTHENTICATION_ERROR + assert not seen + + +def test_wba_without_authorities_warns_at_boot(caplog): + from adcp.server.signed_requests import check_request_signature_verification + + config = RequestSignatureVerification( + signer_keys=DiscoveredSignerKeys(), enable_web_bot_auth=True + ) + with caplog.at_level("WARNING"): + check_request_signature_verification(config) + assert "authorities" in caplog.text + + +async def test_replayed_wba_nonce_is_429_without_a_challenge(): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + body = _tool("get_products") + headers = _signed(private, body, created=1000) + options = { + "method": "POST", + "url": TARGET, + "headers": headers, + "body": body, + "signer_keys": StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + "replay_store": InMemoryReplayStore(), + "now": 1001, + } + await verify_wba_request(**options) + with pytest.raises(WbaVerificationError, match="replayed") as replayed: + await verify_wba_request(**options) + assert replayed.value.status == 429 + assert not replayed.value.headers + + +def test_rfc9421_deprecation_warns_only_once_33_is_advertised(monkeypatch): + import warnings + + from adcp.signing import SigningConfig, generate_signing_keypair, load_private_key_pem + + pem, jwk = generate_signing_keypair(kid="legacy-key") + keys = StaticSignerKeys({ORIGIN: {"keys": [jwk]}}) + + def build() -> list[warnings.WarningMessage]: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + RequestSignatureVerification(signer_keys=keys) + SigningConfig(private_key=load_private_key_pem(pem), key_id="legacy-key") + return [w for w in caught if issubclass(w.category, DeprecationWarning)] + + assert not build() + monkeypatch.setattr("adcp._version.COMPATIBLE_ADCP_VERSIONS", ("3.2", "3.3")) + assert len(build()) == 2 + + +# --------------------------------------------------------------------------- +# #1511: the authorization seam +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("outcome", ["principal", "unbound", "denied", "outage", "crash"]) +async def test_injected_authorizer_decides_authority_for_verified_origins(outcome): + from adcp.server.wba_authorization import OriginBindingGate, WbaAuthorizer + + private = ed25519.Ed25519PrivateKey.generate() + calls: list[tuple[str | None, str, Any]] = [] + + class Authorizer: + async def __call__(self, signer, operation, params): + calls.append((signer.agent_url, operation, params)) + if outcome == "principal": + return "principal-9" + if outcome == "denied": + raise AdcpError("PERMISSION_DENIED", message="no", recovery="correctable") + if outcome == "outage": + raise AdcpError("SERVICE_UNAVAILABLE", message="later", recovery="transient") + if outcome == "crash": + raise RuntimeError("bug") + return None + + assert isinstance(Authorizer(), WbaAuthorizer) + config = RequestSignatureVerification( + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [public_directory_key(private)]}}), + enable_web_bot_auth=True, + wba_revocation_checker=_not_revoked, + wba_authorizer=Authorizer(), + unbound_for=frozenset({"get_media_buys"}), + ) + assert not isinstance(config.effective_wba_authorizer, OriginBindingGate) + app, seen = _recording_app(config) + body = _tool("get_media_buys", {"media_buy_id": "mb"}) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: + response = await client.post(TARGET, content=body, headers=_signed(private, body)) + assert calls == [(ORIGIN, "get_media_buys", {"media_buy_id": "mb"})] + assert "Accept-Signature" not in response.headers + if outcome in {"principal", "unbound"}: + assert seen[0].principal_id == ("principal-9" if outcome == "principal" else None) + return + error = response.json()["structuredContent"]["adcp_error"] + assert error["code"] == ("SERVICE_UNAVAILABLE" if outcome == "outage" else "PERMISSION_DENIED") + assert not seen + + +def test_default_authorizer_is_the_binding_gate_built_from_config(): + from adcp.server.wba_authorization import OriginBindingGate + + bindings = OriginBindings() + records = TrustRecordEvaluator() + config = RequestSignatureVerification( + signer_keys=DiscoveredSignerKeys(), + enable_web_bot_auth=True, + origin_bindings=bindings, + trust_records=records, + binding_role="adcp:sales", + ) + assert config.wba_authorizer is None + gate = config.effective_wba_authorizer + assert isinstance(gate, OriginBindingGate) + assert gate.bindings is bindings and gate.records is records and gate.role == "adcp:sales" + + +async def test_binding_gate_classifies_unreadable_arguments_as_spend(): + from adcp.server.wba_authorization import OriginBindingGate + + bindings = OriginBindings() + bindings.bind(ORIGIN, "principal-1") + + async def offline(url, *, allow_private=False): + raise OSError("offline") + + gate = OriginBindingGate(bindings, TrustRecordEvaluator(fetcher=offline)) + signer = VerifiedSigner( + "k" * 43, "ed25519", "sig1", 0, agent_url=ORIGIN, profile="web-bot-auth", revocation="stale" + ) + with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): + await gate(signer, "update_media_buy", None) diff --git a/tests/test_identity_discovery_cache_security.py b/tests/test_identity_discovery_cache_security.py index 0540da771..99e86003b 100644 --- a/tests/test_identity_discovery_cache_security.py +++ b/tests/test_identity_discovery_cache_security.py @@ -18,11 +18,11 @@ TrustRecordAbsentError, TrustRecordError, TrustRecordEvaluator, + TrustRecordUnavailableError, TrustResponse, ) from adcp.signing.wba import ( OriginRevocationChecker, - WbaVerificationError, public_directory_key, ) @@ -171,10 +171,13 @@ async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: await asyncio.sleep(0) release.set() assert (await asyncio.wait_for(first, timeout=5)).operator_domain == "one.com" - with pytest.raises(TrustRecordError, match="owner"): + # The shared bytes list another owner's agent: invalid for this owner, + # which is unreadable rather than a removal. + with pytest.raises(TrustRecordUnavailableError, match="invalid"): await asyncio.wait_for(second, timeout=5) assert calls == 1 assert not records._hosts_inflight + assert not records._foreign_inflight async def test_shared_hop_host_limits_other_paths_without_blocking_distinct_host(): @@ -194,7 +197,9 @@ async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: json.dumps({"authoritative_location": f"https://hosting.com/{path}.json"}).encode() ) - records = TrustRecordEvaluator(fetcher=fetch, max_fetches_per_host=1) + # hosting.com is outside both owners' registrable domains: their hops + # share the separate foreign-host budget. + records = TrustRecordEvaluator(fetcher=fetch, max_foreign_fetches_per_host=1) first = asyncio.create_task(records.evaluate("https://buyer.one.com")) try: assert await asyncio.wait_for(started.wait(), timeout=5) @@ -309,7 +314,7 @@ async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: json.dumps({"authoritative_location": f"https://hosting.com/{host}.json"}).encode() ) - records = TrustRecordEvaluator(fetcher=fetch, max_fetches_per_host=4) + records = TrustRecordEvaluator(fetcher=fetch, max_foreign_fetches_per_host=4) existing = [ asyncio.create_task(records.evaluate(f"https://owner-{index}.com")) for index in range(4) ] @@ -329,7 +334,7 @@ async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: 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._foreign_inflight assert not records._hosts_inflight @@ -393,11 +398,10 @@ async def fetch(uri, **kwargs): 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 await checker.status(f"https://failed-{index}.agency.com", jwk["kid"]) == "stale" assert not checker._checkers assert len(checker._failures) == 1 - assert not await checker("https://buyer.legitimate.com", jwk["kid"]) + assert await checker.status("https://buyer.legitimate.com", jwk["kid"]) == "current" assert len(checker._checkers) == 1 @@ -418,10 +422,9 @@ async def fetch(uri, **kwargs): 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 await checker.status(origin, jwk["kid"]) == "stale" + # Cooling down: still stale, with no second fetch. + assert await checker.status(origin, jwk["kid"]) == "stale" assert calls == 1 now[0] += 60 assert await checker(origin, jwk["kid"]) @@ -431,7 +434,7 @@ async def fetch(uri, **kwargs): assert origin not in checker._failures -async def test_operator_revocation_admission_quota_leaves_room_for_other_operators(): +async def test_operator_revocation_quota_evicts_only_that_operators_state(): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) @@ -446,11 +449,16 @@ async def fetch(uri, **kwargs): ) 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 + assert await checker.status("https://buyer.other.com", jwk["kid"]) == "current" + # Over its operator's quota: the operator's own least recently used list + # state is evicted (revoked facts are kept elsewhere), never another + # operator's, and the new origin is never locked out. + assert await checker.status("https://buyer-3.agency.com", jwk["kid"]) == "current" + assert list(checker._checkers) == [ + "https://buyer-1.agency.com", + "https://buyer.other.com", + "https://buyer-3.agency.com", + ] async def test_revocation_first_discovery_is_shared_and_not_cancelled_by_one_waiter(): diff --git a/tests/test_identity_history_admission.py b/tests/test_identity_history_admission.py index 0b5ba2dc7..db184d3a0 100644 --- a/tests/test_identity_history_admission.py +++ b/tests/test_identity_history_admission.py @@ -10,9 +10,6 @@ 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, @@ -21,24 +18,20 @@ ) 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, + WbaDiscoveryError, WbaUnavailableError, + WbaVerificationError, 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]) @@ -46,8 +39,10 @@ "factory,parameter", [ (TrustRecordEvaluator, "max_history_records"), + (TrustRecordEvaluator, "max_history_records_per_operator"), (TrustRecordEvaluator, "max_records"), (TrustRecordEvaluator, "max_fetches_per_host"), + (TrustRecordEvaluator, "max_foreign_fetches_per_host"), (DiscoveredSignerKeys, "max_origins"), (DiscoveredSignerKeys, "max_origins_per_operator"), (OriginRevocationChecker, "max_facts_per_origin"), @@ -55,21 +50,17 @@ (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(): +async def test_private_tenant_flood_cannot_evict_trust_publication_or_last_record(): now, modes, calls = [1000.0], {}, [] async def fetch(url, **kwargs): @@ -94,14 +85,15 @@ async def fetch(url, **kwargs): 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 + assert len(records._seen_200) == len(records._last_record) == 3 + assert victim in records._last_record + # A 404 after a read is a discovery failure, never a removal. modes[victim], now[0] = "removed", 1061 - with pytest.raises(TrustRecordNotFoundError): + with pytest.raises(TrustRecordUnavailableError): await records.evaluate(victim) modes[victim], now[0] = "offline", 1122 - with pytest.raises(TrustRecordNotFoundError): - await records.evaluate(victim, allow_stale_on_outage=True) + stale = await records.evaluate(victim, allow_stale_on_outage=True) + assert stale.stale_on_outage assert victim in records._seen_200 assert len(records._history_owners) == 3 @@ -168,7 +160,7 @@ async def fetch(url, **kwargs): 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 "unknown.vercel.app" not in records._last_record assert len(records._history_owners) == 2 assert calls == [ "unknown.vercel.app", @@ -215,39 +207,30 @@ async def legacy(request): 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 test_private_tenant_history_quota_refuses_the_provider_group_only(): + """All tenants of a PSL PRIVATE suffix share one authority-history quota.""" 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])) + return TrustResponse(b'{"agents":[]}') - 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 + records = TrustRecordEvaluator( + fetcher=fetch, max_history_records=10, max_history_records_per_operator=2 + ) + await records.evaluate("victim.example.com") + await records.evaluate("tenant-a.vercel.app") + await records.evaluate("tenant-b.vercel.app") + with pytest.raises(TrustRecordUnavailableError, match="history admission"): + await records.evaluate("tenant-c.vercel.app") + # The refusal is local backpressure: never cached as the owner's failure. + assert "tenant-c.vercel.app" not in records._failures + # Another operator still fits, and no admitted history was evicted. + assert (await records.evaluate("other.example.net")).operator_domain == "example.net" + assert {"example.com", "tenant-a.vercel.app", "tenant-b.vercel.app"} <= set( + records._last_record + ) -async def test_directory_operator_pending_quota_does_not_block_other_operators(): +async def test_directory_operator_pending_quota_groups_private_tenants(): private = Ed25519PrivateKey.generate() kid = public_directory_key(private)["kid"] entered, release = asyncio.Event(), asyncio.Event() @@ -262,110 +245,44 @@ async def fetch(url, **kwargs): 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 + fetcher=fetch, max_origins=4, 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 + # Another free tenant of the same hosting suffix shares the quota: a + # local refusal is load shedding (429), not a re-sign challenge. + with pytest.raises(WbaVerificationError, match="operator") as refused: + await keys(kid, signer_origin="https://two.vercel.app") + assert refused.value.status == 429 + assert "Accept-Signature" not in refused.value.headers + assert await keys(kid, signer_origin="https://buyer.victim.com") is not None finally: release.set() - with pytest.raises(WbaUnavailableError): + with pytest.raises(WbaDiscoveryError): await asyncio.wait_for(first, timeout=5) - assert await keys(kid, signer_origin="https://two.attacker.vercel.app") is not None + assert await keys(kid, signer_origin="https://two.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 test_directory_cache_eviction_only_forces_a_refetch(): + private = Ed25519PrivateKey.generate() + kid = public_directory_key(private)["kid"] + calls = [] - 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) + async def fetch(url, **kwargs): + origin = "https://" + urlsplit(url).hostname + calls.append(origin) + return sign_key_directory(agent_origin=origin, private_keys=[private], created=1000) - 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, - ) + keys = DiscoveredSignerKeys(fetcher=fetch, max_origins=2, clock=lambda: 1000) + victim = "https://buyer.victim.com" + assert await keys(kid, signer_origin=victim) is not None + for index in range(5): + assert await keys(kid, signer_origin=f"https://buyer.other-{index}.com") is not None + assert len(keys._cache) == 2 + assert victim not in keys._cache + assert await keys(kid, signer_origin=victim) is not None + assert calls.count(victim) == 2 def signed_list(private, origin, now, revoked): diff --git a/tests/test_identity_preview_schemas.py b/tests/test_identity_preview_schemas.py new file mode 100644 index 000000000..0b36c96df --- /dev/null +++ b/tests/test_identity_preview_schemas.py @@ -0,0 +1,245 @@ +"""Regression tests for the pinned AdCP 3.3 identity preview schemas and types.""" + +from __future__ import annotations + +import json +from importlib.resources import files +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft7Validator, FormatChecker +from pydantic import ValidationError + +from adcp.server.base import ToolContext +from adcp.server.principal import ( + InMemoryPrincipalRecordStore, + PrincipalIdentity, + PrincipalService, +) +from adcp.signing import TrustRecordError, validate_trust_record +from adcp.types.experimental_identity import ( + AgentOnboarding, + AgentOnboardingMode, + AgentOnboardingRequirement, + AgentOnboardingRequirementKind, + BuyerOnboardingPosture, + GetPrincipalResponse, + PrincipalRelationship, + PrincipalRelationshipStatus, + TrustRecord, +) + +SCHEMAS = Path(str(files("adcp.signing").joinpath("_identity_schemas"))) +ORIGIN = "https://buyer.platform.com" +IDENTITY = PrincipalIdentity(ORIGIN, "buyer_agent") + + +def bundle() -> dict[str, Any]: + return json.loads((SCHEMAS / "bundle.json").read_text()) + + +def errors(value: Any, schema_path: str) -> list[str]: + key = schema_path.removesuffix(".json").replace("/", "_").replace("-", "_") + definitions = bundle()["definitions"] + schema = {"$ref": f"#/definitions/{key}", "definitions": definitions} + validator = Draft7Validator(schema, format_checker=FormatChecker()) + return [error.message for error in validator.iter_errors(value)] + + +def trust_record(posture: dict[str, Any], *, record_level: bool) -> dict[str, Any]: + agent: dict[str, Any] = {"url": ORIGIN, "roles": ["adcp:buying"]} + document: dict[str, Any] = {"agents": [agent]} + if record_level: + document["profiles"] = {"adcp": {"onboarding": posture}} + else: + agent["profiles"] = {"adcp": {"onboarding": posture}} + return document + + +# relationship.status ------------------------------------------------------- + + +def test_relationship_status_is_its_own_enum_with_pending_approval(): + definitions = bundle()["definitions"] + assert definitions["enums_principal_relationship_status"]["enum"] == [ + "pending_approval", + "active", + "suspended", + "blocked", + ] + assert definitions["core_principal_relationship"]["properties"]["status"] == { + "$ref": "#/definitions/enums_principal_relationship_status", + "description": "Current state of the relationship.", + } + assert definitions["core_principal_relationship"]["x-added-in"] == "3.3.0" + + +def test_pending_approval_accepted_and_pending_rejected(): + assert errors({"status": "pending_approval"}, "core/principal-relationship.json") == [] + assert errors({"status": "pending"}, "core/principal-relationship.json") + + relationship = PrincipalRelationship.model_validate({"status": "pending_approval"}) + assert relationship.status is PrincipalRelationshipStatus.pending_approval + with pytest.raises(ValidationError): + PrincipalRelationship.model_validate({"status": "pending"}) + with pytest.raises(ValidationError): + GetPrincipalResponse.model_validate( + {"result": {"kind": "unconfigured", "relationship": {"status": "pending"}}} + ) + + +# get_principal version gate ----------------------------------------------- + + +def service(calls: list[str]) -> PrincipalService: + async def relationship(identity: PrincipalIdentity) -> dict[str, Any]: + calls.append(identity.subject) + return {"status": "pending_approval", "setup": {"message": "Apply"}} + + return PrincipalService(InMemoryPrincipalRecordStore(), relationship_resolver=relationship) + + +@pytest.mark.parametrize("version", ["3.3", "3.3-beta.1", "3.3.1", "3.4", "4.0"]) +async def test_relationship_present_when_served_at_3_3_or_later(version): + calls: list[str] = [] + response = await service(calls).get_principal( + IDENTITY, context=ToolContext(resolved_adcp_version=version) + ) + assert isinstance(response, GetPrincipalResponse) + body = response.model_dump(mode="json", exclude_none=True) + assert body["result"]["relationship"]["status"] == "pending_approval" + assert calls == [ORIGIN] + + +@pytest.mark.parametrize("version", ["3.2", "3.0", "3.2-rc.1", "2.5", None, "not-a-version"]) +async def test_relationship_omitted_when_served_below_3_3_or_unknown(version): + calls: list[str] = [] + response = await service(calls).get_principal( + IDENTITY, context=ToolContext(resolved_adcp_version=version) + ) + assert response.model_dump(mode="json", exclude_none=True)["result"] == {"kind": "unconfigured"} + assert calls == [] + + +async def test_relationship_omitted_without_context(): + calls: list[str] = [] + response = await service(calls).get_principal(IDENTITY) + assert "relationship" not in response.model_dump(mode="json", exclude_none=True)["result"] + assert calls == [] + + +# buyer onboarding posture in trust.json ------------------------------------- + + +@pytest.mark.parametrize("record_level", [False, True]) +def test_profiles_adcp_onboarding_validates_on_record_and_agent(record_level): + posture = { + "mode": "open", + "requirements": [{"kind": "sandbox"}, {"kind": "storyboard", "id": "media_buy_seller"}], + "url": "https://platform.com/sellers", + "message": "Sellers meeting these requirements are considered.", + } + document = trust_record(posture, record_level=record_level) + assert errors(document, "trust/v1/trust.json") == [] + validate_trust_record(document, operator_domain="platform.com") + + parsed = TrustRecord.model_validate(document).root + profiles = parsed.profiles if record_level else parsed.agents[0].profiles + assert profiles is not None and profiles.adcp is not None + assert profiles.adcp.onboarding is not None + assert profiles.adcp.onboarding.mode is AgentOnboardingMode.open + + +def test_buyer_and_seller_postures_share_one_mode_enum(): + definitions = bundle()["definitions"] + assert "enums_buyer_onboarding_mode" not in definitions + assert definitions["enums_agent_onboarding_mode"]["enum"] == [ + "open", + "application", + "invite_only", + ] + mode_ref = {"$ref": "#/definitions/enums_agent_onboarding_mode"} + assert definitions["core_buyer_onboarding_posture"]["properties"]["mode"] == mode_ref + assert definitions["core_agent_onboarding_tier"]["properties"]["mode"] == mode_ref + assert errors({"mode": "considers_unsolicited"}, "core/buyer-onboarding-posture.json") + assert BuyerOnboardingPosture(mode="invite_only").mode is AgentOnboardingMode.invite_only + + +@pytest.mark.parametrize("record_level", [False, True]) +def test_agent_identity_requirement_kind_rejected(record_level): + kinds = bundle()["definitions"]["enums_agent_onboarding_requirement_kind"]["enum"] + assert "agent_identity" not in kinds + posture = {"mode": "application", "requirements": [{"kind": "agent_identity"}]} + assert errors(posture, "core/buyer-onboarding-posture.json") + assert errors( + {"sandbox": {"mode": "application", "requirements": [{"kind": "agent_identity"}]}}, + "core/agent-onboarding.json", + ) + with pytest.raises(TrustRecordError): + validate_trust_record( + trust_record(posture, record_level=record_level), operator_domain="platform.com" + ) + # Readers keep the retired kind as an opaque string, never as a known enum member. + requirement = AgentOnboardingRequirement.model_validate({"kind": "agent_identity"}) + assert not isinstance(requirement.kind, AgentOnboardingRequirementKind) + assert requirement.kind == "agent_identity" + + +def test_documentation_url_is_no_longer_a_declared_field(): + definitions = bundle()["definitions"] + for key in ("core_agent_onboarding", "core_buyer_onboarding_posture"): + assert "documentation_url" not in definitions[key]["properties"] + assert "documentation_url" not in AgentOnboarding.model_fields + assert "documentation_url" not in BuyerOnboardingPosture.model_fields + # The postures stay open to extension (additionalProperties: true upstream), so a + # stray documentation_url is an untyped extension value, not a validated URI. + posture = {"mode": "application", "documentation_url": "not a uri"} + assert errors(posture, "core/buyer-onboarding-posture.json") == [] + assert ( + errors( + {"sandbox": {"mode": "open"}, "documentation_url": "not a uri"}, + "core/agent-onboarding.json", + ) + == [] + ) + + +def test_aao_verified_uses_verified_specialisms(): + requirement = { + "kind": "aao_verified", + "role": "media-buy", + "verification_modes": ["storyboard"], + "verified_specialisms": ["sales-guaranteed"], + "adcp_version": "3.3", + } + properties = bundle()["definitions"]["core_agent_onboarding_requirement"]["properties"] + assert "verified_specialisms" in properties + assert "specialisms" not in properties + assert errors(requirement, "core/agent-onboarding-requirement.json") == [] + assert errors( + {**requirement, "verified_specialisms": []}, "core/agent-onboarding-requirement.json" + ) + document = trust_record({"mode": "open", "requirements": [requirement]}, record_level=False) + validate_trust_record(document, operator_domain="platform.com") + + parsed = AgentOnboardingRequirement.model_validate(requirement) + assert parsed.kind is AgentOnboardingRequirementKind.aao_verified + assert parsed.verified_specialisms == ["sales-guaranteed"] + + +# provenance --------------------------------------------------------------- + + +def test_upstream_lists_exactly_the_pinned_schema_files(): + upstream = json.loads((SCHEMAS / "UPSTREAM.json").read_text()) + present = { + path.relative_to(SCHEMAS).as_posix() + for path in SCHEMAS.rglob("*.json") + if path.name not in {"UPSTREAM.json", "bundle.json"} + } + assert set(upstream["sources"]) == present + for source in upstream["sources"].values(): + assert len(source["commit"]) == 40 + int(source["commit"], 16) + assert set(upstream["composition"]) <= present diff --git a/tests/test_identity_protocol_regressions.py b/tests/test_identity_protocol_regressions.py index f5e5ffaa0..8cbcb3395 100644 --- a/tests/test_identity_protocol_regressions.py +++ b/tests/test_identity_protocol_regressions.py @@ -80,8 +80,12 @@ async def test_wba_missing_operator_record_never_reaches_legacy_authority(): result = await evaluator.check( agent_origin=ORIGIN, brand_domain="brand.com", account_operator="platform.com" ) + # The signer's operator never published a record: it is not listed, + # which is a denial without details, not an outage. assert not result.authorized - assert result.reason == "signer_trust_record_unavailable" + assert result.reason == "signer_not_listed" + assert result.error_code == "PERMISSION_DENIED" + assert not result.include_grant_absent_details assert not legacy_calls assert fetch_calls == [trust_record_url(ORIGIN)] @@ -198,8 +202,11 @@ async def test_non_buying_operation_cannot_inherit_default_media_buying_scope(): 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"): + with pytest.raises(AdcpError) as error: await gate.require_account(Account(id="existing"), buyer, operation="build_creative") + # The seller chose no scope: a seller-side refusal, never grant_absent. + assert error.value.code == "PERMISSION_DENIED" + assert not error.value.details assert not fetch_calls and not legacy_calls @@ -389,7 +396,8 @@ async def fetch(url, *, allow_private=False): (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", {}), + # An invalid record is unreadable: an outage, never grant_absent. + (trust_record_url("brand.com"), {"unexpected": True}, "SERVICE_UNAVAILABLE", {}), ( trust_record_url("brand.com"), {"grants": []}, @@ -488,7 +496,8 @@ async def fetch(url, *, allow_private=False): 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 + # A stale record expires at the end of the outage-continuation bound. + assert cached.expires_at == 1300 assert cached.revision == initial.revision await records.validate_snapshots((cached,), origin_or_domain=ORIGIN, role="adcp:buying") else: @@ -542,7 +551,7 @@ async def fetch(url, *, allow_private=False): @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): +async def test_grantor_404_after_a_read_is_an_outage_never_grant_absent(grantor, status): now, mode, legacy_calls = [1000.0], ["published"], [] async def fetch(url, *, allow_private=False): @@ -577,11 +586,15 @@ async def legacy(request): 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" + # Spend: the retryable outage, never grant_absent. + spend = await evaluator.check(**claim) + assert not spend.authorized + assert spend.error_code == "SERVICE_UNAVAILABLE" + assert not spend.include_grant_absent_details + # Non-spend: the last grants read still stand within the bound. + nonspend = await evaluator.check(**claim, allow_stale_on_outage=True) + assert nonspend.authorized and nonspend.stale_on_outage + # A grantor that served 200 never falls back to authorized_operators. assert not legacy_calls @@ -810,14 +823,11 @@ async def fetch(url, *, allow_private=False): 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 + # Every status other than 200, a 404/410 after a read included, is an + # outage: the error page never masks it as a removal. + assert fresh.error_code == "SERVICE_UNAVAILABLE" + assert grace.authorized and grace.stale_on_outage + assert grace.outage_expires_at == 1300 @pytest.mark.parametrize( @@ -883,14 +893,15 @@ async def legacy(request): } rejected = await evaluator.check(**claim) assert not rejected.authorized - assert rejected.error_code == ( - "SERVICE_UNAVAILABLE" if read_timeout or total_deadline else "PERMISSION_DENIED" - ) + # A rejected or unreadable 200 body is unreadable: an outage. + assert rejected.error_code == "SERVICE_UNAVAILABLE" assert bool(cancelled) == total_deadline assert not rejected.include_grant_absent_details removed[0], now[0] = True, 1061 + # The observed 200 still supersedes legacy fallback: a later 404 is an + # outage, not absence. removal = await evaluator.check(**claim) assert not removal.authorized - assert removal.reason == "grant_absent" + assert removal.error_code == "SERVICE_UNAVAILABLE" assert not removal.legacy_fallback_allowed assert not legacy_calls diff --git a/tests/test_wba_authorization_errors.py b/tests/test_wba_authorization_errors.py index adc72d003..74852fe70 100644 --- a/tests/test_wba_authorization_errors.py +++ b/tests/test_wba_authorization_errors.py @@ -50,7 +50,7 @@ async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: def changing_grant_gate( - *, initial: str, max_denials: int = 4096, grant_valid_until: str | None = None + *, initial: str, grant_valid_until: str | None = None ) -> tuple[TrustGrantGate, list[str], list[float]]: mode, now = [initial], [1000.0] @@ -77,7 +77,6 @@ async def fetch(url: str, *, allow_private: bool = False) -> TrustResponse: gate = TrustGrantGate( TrustGrantEvaluator(records), lambda account, agent: account.metadata["claim"], - max_denials=max_denials, ) return gate, mode, now @@ -89,7 +88,6 @@ async def require_existing_claim( Account(id="existing", metadata={"claim": claim}), buyer, operation="get_products", - bound_wba_identity=True, ) @@ -127,8 +125,8 @@ async def test_positive_claim_outage_continuity_has_a_fixed_grace_deadline( 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. + # Freshness expires at 1060; an outage read never refreshes the + # outage-continuation deadline (four polling intervals: 1300). now[0] = 1060 + grace_offset if allowed: await require_existing_claim(gate) @@ -140,9 +138,7 @@ async def test_positive_claim_outage_continuity_has_a_fixed_grace_deadline( 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 - ) + await gate.require_account(Account(id="existing"), BUYER, operation="get_products") async def test_outage_grace_never_extends_an_explicit_grant_expiry() -> None: @@ -152,9 +148,12 @@ async def test_outage_grace_never_extends_an_explicit_grant_expiry() -> None: await require_existing_claim(gate) mode[0], now[0] = "offline", 1061 await require_existing_claim(gate) + # The last grant read has expired on its own window: it is absent. now[0] = 1070 - with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): + with pytest.raises(AdcpError) as expired: await require_existing_claim(gate) + assert expired.value.code == "PERMISSION_DENIED" + assert expired.value.details == {"scope": "agent", "reason": "grant_absent"} async def test_claim_aliases_and_country_order_cannot_bypass_known_denial() -> None: @@ -176,84 +175,21 @@ async def test_claim_aliases_and_country_order_cannot_bypass_known_denial() -> N 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": + if failure in {"offline", "invalid"}: + # An unreadable or invalid grantor record is an outage, never grant_absent. 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 {} - ) + assert error.details == {"scope": "agent", "reason": "grant_absent"} @pytest.mark.parametrize( @@ -274,9 +210,7 @@ async def test_bound_existing_account_nonspend_continues_on_true_outage( 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 - ) + await gate.require_account(account, BUYER, operation=operation, operation_params=params) now[0] += 61 @@ -286,9 +220,7 @@ async def test_provisioning_spend_and_unknown_operations_require_fresh_authority ) -> 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 - ) + await gate.require_account(Account(id="existing"), BUYER, operation=operation) # An unknown task also requires its own explicit grant scope. assert rejected.value.code == ( "PERMISSION_DENIED" if operation == "unknown_operation" else "SERVICE_UNAVAILABLE" @@ -323,7 +255,6 @@ async def test_resume_and_financial_changes_require_fresh_grant_authority( BUYER, operation="update_media_buy", operation_params=params, - bound_wba_identity=True, ) @@ -334,7 +265,6 @@ async def test_seller_spend_classification_is_authoritative_for_nonspend_default Account(id="existing"), BUYER, operation="sync_creatives", - bound_wba_identity=True, spend_committing=True, ) @@ -395,7 +325,7 @@ async def lookup(origin: str) -> BuyerAgent: ) -async def test_unbound_read_does_not_inherit_cached_commercial_binding() -> None: +async def test_read_without_a_prior_record_cannot_continue_through_an_outage() -> None: gate, _ = grant_gate("offline") with pytest.raises(AdcpError, match="SERVICE_UNAVAILABLE"): await gate.require_account(Account(id="existing"), BUYER, operation="get_products") @@ -412,10 +342,11 @@ async def test_unestablished_agent_keeps_oracle_clamp_even_during_outage() -> No @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 - ) + with pytest.raises(AdcpError) as rejected: + await gate.require_account(Account(id="existing"), BUYER, operation="get_products") + assert rejected.value.code == ( + "SERVICE_UNAVAILABLE" if failure == "invalid" else "PERMISSION_DENIED" + ) @pytest.mark.parametrize("transport", ["mcp", "a2a"]) @@ -515,8 +446,9 @@ async def not_revoked(origin: str, keyid: str) -> bool: 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") + outage = failure in {"offline", "invalid"} + assert error["code"] == ("SERVICE_UNAVAILABLE" if outage else "PERMISSION_DENIED") + assert error["recovery"] == ("transient" if outage else "correctable") assert error.get("details", {}) == ( {"scope": "agent", "reason": "grant_absent"} if failure == "grant_absent" else {} ) @@ -579,15 +511,38 @@ async def call_tool( @pytest.mark.parametrize("transport", ["mcp", "a2a"]) @pytest.mark.parametrize( "signer_failure", - ["revoked", "directory_offline", "directory_503", "directory_429", "revocation_offline"], + [ + "revoked", + "directory_offline", + "directory_503", + "directory_429", + "directory_404", + "replay_outage", + "revocation_offline", + ], ) -async def test_revoked_key_projects_error_without_authentication_or_business_effects( +async def test_signer_failures_never_authenticate_or_reach_business_effects( transport: str, signer_failure: str, monkeypatch: pytest.MonkeyPatch ) -> None: + """DR-0023: the signature path answers with WBA statuses only, never 503. + + A revoked key is 403 without Accept-Signature; a directory that cannot be + resolved leaves the request unverified (403 with Accept-Signature); both + carry the generic authentication error. A replay-store outage is an + application-layer SERVICE_UNAVAILABLE. An origin whose revocation list + cannot be read is still identity-established: unbound, it gets what an + anonymous caller gets. + """ 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 + from adcp.signing import ( + DirectoryResponse, + DiscoveredSignerKeys, + InMemoryReplayStore, + OriginRevocationChecker, + ) + from adcp.signing.wba import GENERIC_AUTHENTICATION_ERROR monkeypatch.setenv("ADCP_SANDBOX", "1") private = Ed25519PrivateKey.generate() @@ -625,11 +580,15 @@ async def revoked(origin: str, keyid: str) -> bool: 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) + return DirectoryResponse(b"", {}, status=int(signer_failure.rpartition("_")[2])) async def revocation_fetch(url: str, *, allow_private: bool = False) -> Any: raise OSError("revocation list unavailable past grace") + class BrokenReplayStore(InMemoryReplayStore): + def claim(self, keyid: str, nonce: str, ttl_seconds: float) -> Any: + raise RuntimeError("replay store offline") + signer_keys = ( DiscoveredSignerKeys(fetcher=directory_fetch) if signer_failure.startswith("directory_") @@ -645,6 +604,9 @@ async def revocation_fetch(url: str, *, allow_private: bool = False) -> Any: signer_keys=signer_keys, enable_web_bot_auth=True, wba_revocation_checker=revocation_checker, + replay_store=( + BrokenReplayStore() if signer_failure == "replay_outage" else InMemoryReplayStore() + ), ) app = _build_mcp_and_a2a_app( Handler(), @@ -707,13 +669,26 @@ async def revocation_fetch(url: str, *, allow_private: bool = False) -> Any: "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 + if signer_failure == "revoked" or signer_failure.startswith("directory_"): + assert response.status_code == 403 + assert response.json() == GENERIC_AUTHENTICATION_ERROR + # Re-sign on a discovery failure; stop using a revoked key. + assert ("accept-signature" in response.headers) == (signer_failure != "revoked") + assert not observations + return + assert "accept-signature" not in response.headers + if signer_failure == "revocation_offline": + # Identity-established and unbound: what an anonymous caller gets, + # here the bearer challenge, with no business effect. + assert response.status_code == 401 + assert not observations + return + response.raise_for_status() + assert observations == [transport] wire = response.json()["result"] if transport == "mcp": decoded = CallToolResult.model_validate(wire) @@ -727,9 +702,7 @@ async def revocation_fetch(url: str, *, allow_private: bool = False) -> Any: 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 error is not None and error["code"] == "SERVICE_UNAVAILABLE" + assert error["recovery"] == "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 index 572cfbac5..ef0d9f003 100644 --- a/tests/test_wba_binding_authority.py +++ b/tests/test_wba_binding_authority.py @@ -34,7 +34,8 @@ sign_wba_request, ) from adcp.signing.middleware import VERIFIED_SIGNER_SCOPE_KEY -from adcp.signing.trust import TrustRecordMatchError, TrustRecordNotFoundError +from adcp.signing.trust import TrustRecordMatchError, TrustRecordUnavailableError +from adcp.signing.wba import GENERIC_AUTHENTICATION_ERROR ORIGIN = "https://buyer.platform.com" TARGET = "https://seller.com/mcp" @@ -52,28 +53,27 @@ def test_a2a_list_tasks_alias_is_nonspend_and_unknown_operations_fail_closed(): assert is_wba_spend_committing("") -@pytest.mark.parametrize("revoked", [False, True, "unavailable"]) +@pytest.mark.parametrize("state", ["revoked", "required", "anonymous", "revocation_unreadable"]) @pytest.mark.parametrize("transport", ["mcp", "a2a"]) -async def test_standalone_middleware_never_calls_app_that_ignores_pending_denial( - revoked, transport -): +async def test_standalone_middleware_renders_layered_outcomes(state, transport): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) invoked = [] async def check_key(origin, keyid): - if revoked == "unavailable": + if state == "revocation_unreadable": raise OSError("private outage details") - return revoked + return state == "revoked" async def unsafe_dispatch(scope, receive, send): - invoked.append(True) + invoked.append(scope.get(VERIFIED_SIGNER_SCOPE_KEY)) 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, + required_for=frozenset({"create_media_buy"}) if state == "required" else frozenset(), ) app = SignedRequestVerificationMiddleware(unsafe_dispatch, config, transport=transport) payload = { @@ -111,26 +111,34 @@ async def unsafe_dispatch(scope, receive, send): ) 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 + if state == "revoked": + # Signature path: 403, the generic authentication error. + assert response.status_code == 403 + assert response.json() == GENERIC_AUTHENTICATION_ERROR + assert not invoked + elif state == "required": + # Authorization layer: the AdCP envelope, no details. + assert response.status_code == 403 + (error,) = response.json()["errors"] + assert error["code"] == "PERMISSION_DENIED" + assert not error.get("details") + assert not invoked + else: + # An unbound, identity-established signer outside unbound_for is + # served exactly as an anonymous request: no verified signer. + assert response.status_code == 200 + assert invoked == [None] @asynccontextmanager async def bound_client( - *, transport="mcp", unbound_for=(), spend_classifier=None, defer_authorization_errors=True + *, + transport="mcp", + unbound_for=(), + spend_classifier=None, + defer_authorization_errors=True, + required_for=(), ): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) @@ -154,7 +162,13 @@ async def fetch(url, *, allow_private=False): bindings = OriginBindings() approved = await records.bind( VerifiedSigner( - jwk["kid"], "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + jwk["kid"], + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", ), principal_id="principal-1", bindings=bindings, @@ -168,6 +182,7 @@ async def fetch(url, *, allow_private=False): trust_records=records, wba_revocation_checker=not_revoked, unbound_for=frozenset(unbound_for), + required_for=frozenset(required_for), **options, ) observed = [] @@ -181,7 +196,13 @@ async def dispatch(scope, receive, send): scope, receive, send ) return - principal = context.metadata["adcp.auth_info"].extra["principal_id"] + auth_info = context.metadata.get("adcp.auth_info") if context is not None else None + if auth_info is None: + # Served exactly as an anonymous request: no verified signer. + observed.append(("anonymous", None)) + await JSONResponse({"principal": None, "anonymous": True})(scope, receive, send) + return + principal = auth_info.extra["principal_id"] user = scope.get("user") observed.append((principal, getattr(user, "display_name", None))) await JSONResponse({"principal": principal})(scope, receive, send) @@ -219,9 +240,9 @@ async def post(operation, arguments=None, *, method="tools/call"): 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 ( +@pytest.mark.parametrize("mode_name", ["delisted", "role_changed"]) +async def test_delisted_binding_falls_back_to_identity_established_without_rebinding(mode_name): + async with bound_client(unbound_for={"get_products"}, required_for={"create_media_buy"}) as ( post, now, mode, @@ -236,19 +257,57 @@ async def test_delisted_binding_retains_anonymous_reads_principal_and_prevents_r response = await post("get_products") assert response.json()["principal"] is None assert (await post("get_principal")).json()["principal"] is None + # Anything else is what an anonymous caller gets. + assert (await post("get_media_buys")).json() == {"principal": None, "anonymous": True} + # The binding is kept, so no RFC 9421 downgrade; it cannot be bound again. assert await bindings(ORIGIN) == "principal-1" signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + "kid", + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", ) - with pytest.raises((TrustRecordMatchError, ValueError)): + with pytest.raises(TrustRecordMatchError): await records.bind(signer, principal_id="principal-1", bindings=bindings) + # A known delisting survives a later outage. 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" + error = response.json()["structuredContent"]["adcp_error"] + assert error["code"] == "PERMISSION_DENIED" + assert not error.get("details") assert "Accept-Signature" not in response.headers +@pytest.mark.parametrize("failure", ["removed", "invalid", "offline"]) +async def test_operator_404_or_invalid_record_after_a_read_is_an_outage_not_a_delisting(failure): + 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] = failure, 1061 + # Non-spend continues under the binding within the outage bound. + assert (await post("get_products")).json()["principal"] == "principal-1" + spend = (await post("create_media_buy")).json()["structuredContent"]["adcp_error"] + assert spend["code"] == "SERVICE_UNAVAILABLE" + assert spend["recovery"] == "transient" + now[0] = 1300 + late = (await post("get_products")).json()["structuredContent"]["adcp_error"] + assert late["code"] == "SERVICE_UNAVAILABLE" + mode[0], now[0] = "valid", 1361 + assert (await post("get_products")).json()["principal"] == "principal-1" + + @pytest.mark.parametrize( "operation,arguments", [ @@ -275,18 +334,24 @@ async def test_binding_outage_grace_boundary_is_shared_across_transport_ports(): dispatch, config, transport="a2a", defer_authorization_errors=True ) signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + "kid", + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", ) - established, error = await a2a._authorize_wba_signer(signer, "get_media_buys") + principal, error = await a2a._authorize_wba_binding(signer, "get_media_buys") assert error is None - assert established.principal_id == "principal-1" + assert principal == "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") + _, error = await a2a._authorize_wba_binding(signer, "get_media_buys") assert error.code == "SERVICE_UNAVAILABLE" assert len(observed) == 2 @@ -342,20 +407,23 @@ async def delayed_evaluate(origin, **kwargs): 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. + except TrustRecordUnavailableError: + # A 404 after a read is an outage: it supersedes nothing. assert newer_policy == "removed" return captured monkeypatch.setattr(records, "evaluate", delayed_evaluate) response = await post("get_media_buys") - if newer_policy == "expired": + if newer_policy in {"expired", "removed"}: + # The captured record expired while it was evaluated; a 404 is an + # outage, so nothing newer replaces it, and spend-free calls only + # continue on a record that is still inside its bound. assert ( response.json()["structuredContent"]["adcp_error"]["code"] == "SERVICE_UNAVAILABLE" ) assert not observed else: + # A newer delisting read by another origin wins over the captured listing. assert response.json()["principal"] is None assert observed == [(None, None)] @@ -371,9 +439,15 @@ async def unavailable(url, *, allow_private=False): dispatch, replace(config, trust_records=restarted), transport="mcp" ) signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + "kid", + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", ) - _, error = await middleware._authorize_wba_signer(signer, "get_media_buys") + _, error = await middleware._authorize_wba_binding(signer, "get_media_buys") assert error.code == "SERVICE_UNAVAILABLE" assert error.recovery == "transient" @@ -407,17 +481,24 @@ async def test_other_origins_delisting_read_survives_cache_eviction_and_local_ca # 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 + dispatch, replace(config), transport="a2a", defer_authorization_errors=True ) signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + "kid", + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", + ) + # Delisted: identity-established (no principal, no error), even + # for spend, never an outage. + assert await other_port._authorize_wba_binding(signer, "create_media_buy") == ( + None, + None, ) - 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() @@ -466,32 +547,6 @@ async def test_seller_can_classify_known_budget_reductions_using_local_state(): ] == "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, @@ -510,15 +565,19 @@ async def test_delisted_membership_is_shared_across_mcp_and_a2a_middleware(): dispatch, config, transport="a2a", defer_authorization_errors=True ) signer = VerifiedSigner( - "kid", "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + "kid", + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", ) - established, error = await a2a._authorize_wba_signer(signer, "create_media_buy") - assert established.principal_id is None - assert error.code == "PERMISSION_DENIED" + assert await a2a._authorize_wba_binding(signer, "create_media_buy") == (None, None) @pytest.mark.parametrize("method", ["tasks/get", "tasks/cancel"]) -async def test_delisted_a2a_origin_keeps_only_historical_task_owner(method): +async def test_delisted_a2a_origin_gets_no_task_control_exception(method): async with bound_client(transport="a2a") as ( post, now, @@ -534,7 +593,9 @@ async def test_delisted_a2a_origin_keeps_only_historical_task_owner(method): mode[0], now[0] = "delisted", 1061 response = await post("unused", method=method) assert response.status_code == 200 - assert observed == [("principal-1", ORIGIN), (None, ORIGIN)] + # Identity-established for every request, task polling included: + # served exactly as an anonymous caller, with no historical owner. + assert observed == [("principal-1", ORIGIN), ("anonymous", None)] assert "Accept-Signature" not in response.headers @@ -563,7 +624,13 @@ async def fetch(url, *, allow_private=False): bindings = OriginBindings() await records.bind( VerifiedSigner( - jwk["kid"], "ed25519", "sig1", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + jwk["kid"], + "ed25519", + "sig1", + time.time(), + agent_url=ORIGIN, + profile="web-bot-auth", + revocation="current", ), principal_id="principal-1", bindings=bindings, @@ -575,6 +642,7 @@ async def fetch(url, *, allow_private=False): trust_records=records, wba_revocation_checker=not_revoked, signing_profile_version="3.2", + required_for=frozenset({"create_media_buy"}), ) async def dispatch(scope, receive, send): @@ -705,6 +773,7 @@ async def dispatch(scope, receive, send): enable_web_bot_auth=True, wba_revocation_checker=not_revoked, unbound_for=frozenset({operation}), + required_for=frozenset({"create_media_buy"}), ) app = SignedRequestVerificationMiddleware( dispatch, config, transport="mcp", defer_authorization_errors=True diff --git a/tests/test_wba_outage_security.py b/tests/test_wba_outage_security.py index 1d0a92f25..a988aa82f 100644 --- a/tests/test_wba_outage_security.py +++ b/tests/test_wba_outage_security.py @@ -1,4 +1,4 @@ -"""Signer evidence outages never become anonymous or stale identity approval.""" +"""Signer evidence outages are discovery failures, never a 503 on the signature path.""" from __future__ import annotations @@ -23,7 +23,7 @@ DirectoryResponse, DiscoveredSignerKeys, OriginRevocationChecker, - WbaUnavailableError, + WbaDiscoveryError, WbaVerificationError, fetch_key_directory, public_directory_key, @@ -92,21 +92,21 @@ async def fetch(url, *, allow_private=False): 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) + # WBA §6.10: every outcome is a discovery failure, the request is + # unverified, and the signature path never answers 503. + assert isinstance(caught.value, WbaDiscoveryError) + assert caught.value.discovery_failure + assert caught.value.status == 403 + assert caught.value.headers.get("Accept-Signature") 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()) + # The cooldown keeps only a deadline, never a raised exception object. + assert all(isinstance(value, float | int) for value in resolver._failures.values()) async def test_fetched_directory_without_requested_key_is_absence_not_outage(): @@ -213,10 +213,13 @@ async def fetch(uri, **kwargs): clock=lambda: now[0], wall_clock=lambda: start + timedelta(seconds=now[0]), ) - with pytest.raises(WbaUnavailableError): - await checker(ORIGIN, jwk["kid"]) + # A stale first list is refused as current, but still revokes what it revoked. + assert await checker.status(ORIGIN, jwk["kid"]) == "revoked" + assert 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"]) + # A later list that omits the revocation cannot withdraw it. + assert await checker.status(ORIGIN, "unrevoked-kid-" + "x" * 29) == "current" assert len(calls) == 2 + assert await checker(ORIGIN, jwk["kid"]) diff --git a/tests/test_wba_prior_task_controls.py b/tests/test_wba_task_ownership.py similarity index 75% rename from tests/test_wba_prior_task_controls.py rename to tests/test_wba_task_ownership.py index 03f0f1e84..9ae764670 100644 --- a/tests/test_wba_prior_task_controls.py +++ b/tests/test_wba_task_ownership.py @@ -1,4 +1,4 @@ -"""Delisting preserves only the verified origin's previously owned A2A work.""" +"""A2A task ownership follows the verified origin; a delisted origin gets no exception.""" from __future__ import annotations @@ -17,7 +17,7 @@ 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.auth import BearerTokenAuth, Principal, _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 ( @@ -71,7 +71,7 @@ async def not_revoked(origin: str, key_id: str) -> bool: async def validate_token(token: str): bearer_lookups.append(token) - return "bearer-principal" + return Principal(caller_identity="bearer-principal") class Store(InMemoryTaskStore): signer_unavailable = False @@ -325,47 +325,32 @@ def assert_not_found(response: httpx.Response) -> None: 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 -): +@pytest.mark.parametrize( + "method", ["tasks/get", "tasks/cancel", "GetTask", "CancelTask", "SubscribeToTask"] +) +async def test_delisted_origin_gets_no_task_control_exception(task_env, method): 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 + controlled.clear() + # Identity-established for every request, polling and cancelling the + # tasks it created while listed included: what an anonymous caller gets. + anonymous = await post(method, {"id": task_id}) + assert anonymous.status_code == 401, anonymous.text + assert "accept-signature" not in anonymous.headers + # An independent bearer credential is its own owner, never the origin's. + with_bearer = await post(method, {"id": task_id}, bearer=True) + assert_not_found(with_bearer) + assert (task_id, ORIGIN) not in controlled 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): +async def test_active_tasks_cannot_be_controlled_by_another_origin(task_env, method): 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( @@ -381,22 +366,6 @@ async def test_active_tasks_cannot_be_controlled_by_another_origin(task_env, met 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() @@ -405,44 +374,6 @@ async def test_delisted_task_listing_is_not_admitted(task_env): 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 @@ -466,30 +397,12 @@ async def replaced(self: Any, task_id: str, *args: Any, **kwargs: Any): 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): +async def test_delisted_principal_introspection_stays_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 - 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() + await start() mode[0], now[0] = "delisted", 1061 - assert (await post("GetTask", {"id": task_id})).status_code == 200 + assert (await post("GetTask", {"id": "any"})).status_code == 401 mode[0], now[0] = "offline", 1122 - assert (await post("GetTask", {"id": task_id})).json()["result"]["id"] == task_id response = await post( "message/send", { @@ -536,7 +449,6 @@ async def test_new_message_cannot_retarget_a_foreign_active_task(task_env): 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 @@ -545,26 +457,6 @@ async def test_real_client_disconnect_ends_only_the_owned_subscription(task_env, 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 diff --git a/tests/type_checks/experimental_identity.py b/tests/type_checks/experimental_identity.py index 3e9b1e70d..6c4b2031c 100644 --- a/tests/type_checks/experimental_identity.py +++ b/tests/type_checks/experimental_identity.py @@ -25,6 +25,7 @@ PrincipalCurrentResult, PrincipalRecognizedResult, PrincipalRelationship, + PrincipalRelationshipStatus, PrincipalUnconfiguredResult, RequestSigning, ) @@ -44,6 +45,11 @@ def principal_relationship(response: GetPrincipalResponse) -> PrincipalRelations return response.result.relationship +def relationship_pending(relationship: PrincipalRelationship) -> bool: + assert_type(relationship.status, PrincipalRelationshipStatus) + return relationship.status is PrincipalRelationshipStatus.pending_approval + + def onboarding_message(response: GetAdcpCapabilitiesResponse) -> str | None: assert_type(response.account, AccountCapabilities | None) assert_type(response.request_signing, RequestSigning | None)