From a8a1d92c98b4ad16c91a1a142f5283b250a1b669 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 18:41:09 +0000 Subject: [PATCH 01/46] feat(identity): pin identity preview schemas to the final spec branches Update the experimental AdCP 3.3 identity preview schemas from the open spec branches, with provenance in UPSTREAM.json: - relationship.status uses pending_approval from its own enum, and relationship is emitted on get_principal only when served at 3.3 or later. - The buyer onboarding posture lives only in trust.json, at profiles.adcp.onboarding on the record and on an agent entry; sellers and buyers share one mode enum. - The agent_identity requirement kind and documentation_url are gone, and aao_verified uses verified_specialisms. Regenerate the bundle and the preview models, and export PrincipalRelationshipStatus in place of BuyerOnboardingMode. Refs #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/generate_identity_preview.py | 16 +- src/adcp/server/principal.py | 45 +++- .../signing/_identity_schemas/UPSTREAM.json | 61 +++-- .../signing/_identity_schemas/bundle.json | 110 ++++---- .../core/agent-onboarding-requirement.json | 7 +- .../core/agent-onboarding-tier.json | 1 + .../core/agent-onboarding.json | 6 +- .../core/buyer-onboarding-posture.json | 10 +- .../core/principal-relationship.json | 14 +- .../enums/agent-onboarding-mode.json | 9 +- .../agent-onboarding-requirement-kind.json | 7 +- .../enums/buyer-onboarding-mode.json | 14 - .../enums/principal-relationship-status.json | 16 ++ .../_identity_schemas/trust/v1/trust.json | 16 +- src/adcp/types/_identity_preview.py | 82 +++--- src/adcp/types/experimental_identity.py | 11 +- tests/test_experimental_identity.py | 30 ++- tests/test_identity_preview_schemas.py | 245 ++++++++++++++++++ tests/type_checks/experimental_identity.py | 6 + 19 files changed, 505 insertions(+), 201 deletions(-) delete mode 100644 src/adcp/signing/_identity_schemas/enums/buyer-onboarding-mode.json create mode 100644 src/adcp/signing/_identity_schemas/enums/principal-relationship-status.json create mode 100644 tests/test_identity_preview_schemas.py 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/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/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/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/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_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/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) From bb4e56fd99fb767323e7a47ff895a5225b5f2d6f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 18:41:47 +0000 Subject: [PATCH 02/46] fix(identity): align Web Bot Auth, trust records and grants with the final spec Bring the experimental AdCP 3.3 agent identity up to date with the final spec decisions and with the Go and TypeScript SDKs. Signature path, per DR-0023 and WBA 5.5/6.10: - Answer with WBA statuses and headers only, with one generic AUTH_INVALID body and never SERVICE_UNAVAILABLE. A revoked key is 403 without Accept-Signature. - Key-directory discovery failures never evict a cached directory, which keeps verifying up to max_stale_seconds; with nothing cached the request is unverified: 403 with Accept-Signature by default, or anonymous via wba_discovery_failure, except operations in required_for. - verify_wba_request needs only the directory and replay store. Revocation, bindings and grants are the authorization layer; replay and revocation store failures are application-level SERVICE_UNAVAILABLE. Authorization layer: - OriginRevocationChecker.status reports revoked, current, no_list, stale or unavailable. No list means identity-established and unbindable; a stale list fails spend closed. - An unbound caller outside unbound_for gets what an anonymous caller gets. A delisted bound origin falls back to identity-established with no task-control exception, keeps its binding against downgrade, and cannot be bound again while unlisted. - Trust records: the last record read stays authoritative; a 404 or invalid record after a read is an outage, never a removal; spend gets SERVICE_UNAVAILABLE and non-spend continues within a configurable bound. A grantor that served 200 never falls back to authorized_operators and an outage is never grant_absent. - Grants are read from the house record, with registrable-domain comparison, self-operated brands, narrowing and window intersection. Interop: 64-byte nonces, the target URI as received, unsigned JWKS directories by thumbprint, RSA-PSS-SHA512, and the vendored Cloudflare interop fixtures. Hardening: per owner/host trust budgets with a separate foreign-hop budget, PSL PRIVATE-aware per-operator history quotas, backdated directory created, replay at exactly expires plus skew, and fail-closed account claims. Refs #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/decisioning/handler.py | 36 +- src/adcp/decisioning/trust_grants.py | 287 ++-- src/adcp/server/auth.py | 11 +- src/adcp/server/signed_requests.py | 656 ++++----- src/adcp/signing/__init__.py | 8 + src/adcp/signing/etld.py | 31 + src/adcp/signing/revocation_fetcher.py | 19 +- src/adcp/signing/trust.py | 782 ++++++----- src/adcp/signing/verifier.py | 11 +- src/adcp/signing/wba.py | 1089 +++++++++++---- tests/conformance/signing/test_wba_interop.py | 266 ++++ tests/conformance/signing/test_wba_profile.py | 85 +- .../conformance/vectors/wba-interop/README.md | 25 + .../vectors/wba-interop/UPSTREAM.json | 14 + .../adcp-client-signed-body-request.json | 34 + ...cp-client-signed-percent-encoded-path.json | 34 + .../cloudflare-signed-body-request.json | 33 + ...loudflare-signed-percent-encoded-path.json | 33 + .../conformance/vectors/wba-interop/keys.json | 30 + tests/test_identity_3_3_alignment.py | 1175 +++++++++++++++++ .../test_identity_discovery_cache_security.py | 35 +- tests/test_identity_history_admission.py | 199 +-- tests/test_identity_protocol_regressions.py | 50 +- tests/test_wba_authorization_errors.py | 175 ++- tests/test_wba_binding_authority.py | 231 ++-- tests/test_wba_outage_security.py | 29 +- ...controls.py => test_wba_task_ownership.py} | 150 +-- 27 files changed, 3894 insertions(+), 1634 deletions(-) create mode 100644 tests/conformance/signing/test_wba_interop.py create mode 100644 tests/conformance/vectors/wba-interop/README.md create mode 100644 tests/conformance/vectors/wba-interop/UPSTREAM.json create mode 100644 tests/conformance/vectors/wba-interop/adcp-client-signed-body-request.json create mode 100644 tests/conformance/vectors/wba-interop/adcp-client-signed-percent-encoded-path.json create mode 100644 tests/conformance/vectors/wba-interop/cloudflare-signed-body-request.json create mode 100644 tests/conformance/vectors/wba-interop/cloudflare-signed-percent-encoded-path.json create mode 100644 tests/conformance/vectors/wba-interop/keys.json create mode 100644 tests/test_identity_3_3_alignment.py rename tests/{test_wba_prior_task_controls.py => test_wba_task_ownership.py} (75%) 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..1f16a2392 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. @@ -95,32 +78,42 @@ 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 _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. - - 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. + """Apply demand-grant evaluation before account side effects. + + 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 +121,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: + """Authorize ``claim`` for ``agent``; raise the AdCP error otherwise. + + ``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: - 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 + raise _unestablished() try: - await self._require_admitted_claim( - claim, - agent, - signing_profile=signing_profile, - allow_cached_binding_on_outage=allow_cached_binding_on_outage, - ) - finally: - with self._history.lock: - self._history.claims[key] -= 1 - if ( - self._history.claims[key] == 0 - and key not in self._history.positives - and key not in self._history.denials - ): - del self._history.claims[key] - self._history.operator_counts[operator] -= 1 - if not self._history.operator_counts[operator]: - del self._history.operator_counts[operator] - - async def _require_admitted_claim( - self, - claim: TrustGrantClaim, - agent: BuyerAgent | None, - *, - signing_profile: Literal["web-bot-auth", "adcp/request-signing/v1"] = "web-bot-auth", - allow_cached_binding_on_outage: bool = False, - ) -> None: - if agent is None: - # No established commercial identity: omit details under the oracle clamp. + 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 +156,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 +167,39 @@ 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. + raise _grant_absent("Account has no resolvable grant claim") from exc + if not isinstance(claim, TrustGrantClaim): + raise _grant_absent("Account has no resolvable grant claim") if ( operation is not None and operation not in _MEDIA_BUY_DEFAULT_OPERATIONS and claim.scope == "adcp:media_buying" ): - raise AdcpError( - "PERMISSION_DENIED", - message="Operation requires an explicit grant scope", - details={"scope": "agent", "reason": "grant_absent"}, - recovery="correctable", - ) + raise _grant_absent("Operation requires an explicit grant scope") 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=_cached_binding_may_continue(operation, operation_params, spend_committing), + wba_revocation=wba_revocation, ) async def require_declaration( @@ -333,21 +208,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 +229,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/signed_requests.py b/src/adcp/server/signed_requests.py index da3d3c9d9..f79ef8776 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -68,10 +68,12 @@ verify_request_signature, ) from adcp.signing.wba import ( + GENERIC_AUTHENTICATION_ERROR, OriginRevocationChecker, - WbaAuthorizationError, + WbaRevokedKeyError, WbaUnavailableError, WbaVerificationError, + check_wba_revocation, uses_web_bot_auth, verify_wba_request, ) @@ -97,17 +99,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( @@ -120,9 +116,12 @@ 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_" - ): + 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 @@ -183,6 +182,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 +245,31 @@ 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 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. """ signer_keys: SignerKeyResolver @@ -257,16 +290,19 @@ class RequestSignatureVerification: 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 - ) + wba_discovery_failure: Literal["reject", "anonymous"] = "reject" 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.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, @@ -435,8 +471,49 @@ 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: + host = f"{server[0]}:{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: + path = str(scope.get("root_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 +534,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 +554,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, + if not self._config.enable_web_bot_auth: + await self._reject_wba( + scope, + receive, + send, + WbaVerificationError("Web Bot Auth is not enabled", challenge=False), ) - existing, authorization_error = await self._authorize_wba_signer( - existing, target.operation or target.method or "", decision=decision - ) - except WbaVerificationError as exc: - if isinstance(exc, (WbaAuthorizationError, WbaUnavailableError)): - await self._project_wba_authentication_error( - scope, - receive, - send, - json.dumps(existing.parsed_body).encode(), - exc, - ) - return - await self._reject_wba(scope, receive, send, exc) return - await self._call_with_signer( - scope, - receive, - send, - existing, - authorization_error=authorization_error, - prior_task_control=decision.prior_task_control, + 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) @@ -599,17 +643,12 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: await self._app(scope, replay, send) return - decision = _AuthorizationDecision() - try: - signer, authorization_error = await self._verify( - scope, body, label, posture, decision=decision - ) - except WbaVerificationError as exc: - if isinstance(exc, (WbaAuthorizationError, WbaUnavailableError)): - await self._project_wba_authentication_error(scope, replay, send, body, exc) - return - await self._reject_wba(scope, replay, send, exc) + if wba: + await self._serve_wba(scope, replay, send, body, target, posture) 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 +665,140 @@ 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"), + ) + 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 +815,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,108 +854,80 @@ 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.""" - from adcp.decisioning.types import AdcpError + async def _authorize_wba_binding( + self, signer: VerifiedSigner, operation: str + ) -> tuple[str | None, AdcpError | None]: + """Turn a verified origin into its bound principal, or ``None``. + + 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"`` (the revocation store) 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. + """ 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 config.origin_bindings is None or signer.agent_url is None: + return None, None + origin = canonical_origin(signer.agent_url) + try: + principal = await config.origin_bindings(origin) + except Exception: + logger.exception("origin binding lookup failed") + return None, _service_unavailable("Origin binding lookup is temporarily unavailable") if principal is None: - return self._identity_established(signer, operation, decision=decision) + return None, None + spend = self._is_spend_committing(signer, operation) + if signer.revocation == "stale": + if spend: + return None, _service_unavailable( + "Origin revocation status is temporarily unavailable" + ) + elif signer.revocation == "unavailable": + return None, _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, None if config.trust_records is None: - return replace(signer, principal_id=principal), AdcpError( - "SERVICE_UNAVAILABLE", - message="Origin binding evaluation unavailable", - recovery="transient", - ) - try: - record = await config.trust_records.evaluate( - signer.agent_url or "", - role=config.binding_role, - allow_stale_on_outage=not self._is_spend_committing(signer, operation), - ) - await config.trust_records.validate_snapshots( - (record,), origin_or_domain=signer.agent_url or "", role=config.binding_role - ) - except (TrustRecordMatchError, TrustRecordNotFoundError): - self._binding_membership[signer.agent_url or ""] = False - config._delisted_task_origins.add(signer.agent_url or "") - return self._identity_established( - signer, operation, prior_task_control=True, decision=decision - ) - except TrustRecordUnavailableError: - if not self._binding_membership.get(signer.agent_url or "", True): - return self._identity_established( - signer, - operation, - prior_task_control=signer.agent_url in config._delisted_task_origins, - decision=decision, + return None, _service_unavailable("Origin binding evaluation is unavailable") + listing = await config.trust_records.check_listing(origin, role=config.binding_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. + from adcp.signing.trust import TrustRecordError, TrustRecordMatchError + + try: + await config.trust_records.validate_snapshots( + (listing.record,), origin_or_domain=origin, role=config.binding_role ) - return replace(signer, principal_id=principal), AdcpError( - "SERVICE_UNAVAILABLE", - message="Operator trust record is temporarily unavailable", - recovery="transient", - ) - except TrustRecordError as exc: - self._binding_membership[signer.agent_url or ""] = False - config._delisted_task_origins.discard(signer.agent_url or "") - logger.info("operator trust record rejected", extra={"reason": type(exc).__name__}) - return replace(signer, principal_id=None), AdcpError( - "PERMISSION_DENIED", - message="Signer origin is not authorized by its operator", - recovery="correctable", - ) - self._binding_membership[signer.agent_url or ""] = True - config._delisted_task_origins.discard(signer.agent_url or "") - # Preserve the historical binding when membership ends, so delisting - # cannot enable the deprecated transport as a downgrade. - return replace(signer, principal_id=principal), None - - def _identity_established( - self, - signer: VerifiedSigner, - operation: str, - *, - prior_task_control: bool = False, - decision: _AuthorizationDecision | None = None, - ) -> tuple[VerifiedSigner, AdcpError | None]: - from adcp.decisioning.types import AdcpError - - if decision is not None: - decision.prior_task_control = ( - prior_task_control and operation in _PRIOR_TASK_CONTROL_METHODS - ) - error = ( - None - if operation in self._config.unbound_for - or operation == "get_principal" - or (prior_task_control and operation in _PRIOR_TASK_CONTROL_METHODS) - else AdcpError( - "PERMISSION_DENIED", - message="Caller is not authorized for this operation", - recovery="correctable", + except TrustRecordMatchError: + return None, None + except TrustRecordError: + return None, _service_unavailable( + "Operator trust record is temporarily unavailable" + ) + if listing.status == "listed": + return principal, None + if listing.status == "unlisted": + logger.info( + "bound origin is not listed by its operator", + extra={"reason": type(listing.error).__name__}, ) - ) - return replace(signer, principal_id=None), error + return None, None + if spend or not listing.within_grace: + return None, _service_unavailable("Operator trust record is temporarily unavailable") + return principal, None def _is_spend_committing(self, signer: VerifiedSigner, operation: str) -> bool: payload = signer.parsed_body @@ -826,71 +941,48 @@ def _is_spend_committing(self, signer: VerifiedSigner, operation: str) -> bool: else: from adcp.server.a2a_server import parse_a2a_jsonrpc_skill - _skill, arguments = parse_a2a_jsonrpc_skill(dict(payload), self._message_parser) + try: + _skill, arguments = parse_a2a_jsonrpc_skill(dict(payload), self._message_parser) + except Exception: + return True 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 +1027,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 +1047,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 +1059,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 @@ -1044,6 +1100,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 +1174,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 +1248,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 diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index 554df0f1d..5f1db16fc 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, @@ -398,7 +402,11 @@ def __init__(self, *args: object, **kwargs: object) -> None: __all__ = [ "GrantRequest", "WbaAuthorizationError", + "WbaDiscoveryError", + "WbaRevokedKeyError", "WbaUnavailableError", + "check_wba_revocation", + "TrustListing", "RevocationDetails", "OriginRevocationChecker", "EvaluatedTrustRecord", 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/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/trust.py b/src/adcp/signing/trust.py index 4e17470c5..1ebffbec4 100644 --- a/src/adcp/signing/trust.py +++ b/src/adcp/signing/trust.py @@ -32,11 +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. + + A 404/410 from an operator that has served a record is a discovery + failure (:class:`TrustRecordUnavailableError`), never a removal. + """ operator_domain: str | None = None @@ -46,11 +60,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): @@ -240,6 +254,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 +282,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 +309,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__( @@ -284,112 +365,115 @@ def __init__( polling_interval_seconds: float = 60, max_records: int = 256, max_history_records: int = 256, + 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 +485,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 +499,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 +516,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 +598,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 +614,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 +624,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 +637,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 +655,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 +727,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 +781,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 +816,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 +885,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( @@ -790,6 +925,8 @@ def _grant_matches( ) -> bool: if 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 +935,44 @@ 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. +HouseResolver = Callable[[str], Awaitable[str | None]] + - 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. +class TrustGrantEvaluator: + """Evaluate demand-side grants (L2 §Grants in trust.json, DR-0025). + + * One record authorizes: the house's. ``resolve_house(brand_domain)`` + returns the verified house domain the brand's brand.json names (a + house redirect, or a ``house_domain`` the house reciprocates); 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 +982,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 +1012,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 +1027,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 +1064,81 @@ 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) + except Exception: + return _unavailable("brand_house_unavailable") + if resolved is not None: + 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,73 +1148,65 @@ 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 == signer_operator: + # The house grants the signer's operator, which is account.operator. + links = [[g for g in grants if _grant_matches(g, grantee=signer_operator, **args)]] + elif operator == brand_operator: + # 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 ) @@ -1044,8 +1216,10 @@ async def check( "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..bf2cc2937 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 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,177 @@ 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" + +#: A Web Bot Auth nonce is 64 random bytes (architecture draft §4.2.2), the +#: length Cloudflare's ``web-bot-auth`` verifier requires. +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 valid_wba_nonce(nonce: object) -> bool: + """Whether ``nonce`` encodes exactly 64 bytes, canonically. + + Accepts unpadded base64url and padded standard base64, the two encodings + the architecture draft and Cloudflare's ``web-bot-auth`` library use. + """ + 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): + pass + try: + raw = base64.b64decode(nonce, validate=True) + candidates.append((raw, base64.b64encode(raw).decode("ascii"))) + except (binascii.Error, ValueError): + pass + return any(len(raw) == WBA_NONCE_BYTES and encoded == nonce for raw, encoded in candidates) 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 -class WbaUnavailableError(WbaVerificationError): - """Identity cannot be checked during an outage; retry without serving the request.""" + def __init__(self, reason: str, *, label: str = "sig1") -> None: + super().__init__(reason, status=403, label=label) - def __init__(self, reason: str) -> None: - super().__init__(reason, status=503) +class WbaRevokedKeyError(WbaVerificationError): + """The signer's origin revoked the key: 403 without ``Accept-Signature``. -def _dictionary(value: str | None) -> Dictionary: - if value is None or len(value) > 65536: - raise WbaVerificationError("missing or oversized signature field", status=400) + The signer must stop using the key, so a hint to sign again would be + wrong. The body is the generic authentication error. + """ + + revoked = True + + def __init__(self, reason: str = "signing key revoked", *, label: str = "sig1") -> None: + super().__init__(reason, status=403, label=label, challenge=False) + + +#: 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 +277,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 +308,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 +371,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 +441,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 +460,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 +481,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 +530,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]") @@ -307,8 +557,9 @@ def sign_wba_request( _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 + nonce = _new_nonce() if nonce is None else nonce if not 22 <= len(nonce) <= 256: raise ValueError( "nonce must carry at least 128 bits of entropy and be at most 256 characters" @@ -323,21 +574,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 +605,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 +751,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 +806,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 +829,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 +879,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 +892,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 +934,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 +1049,30 @@ 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, ) -> 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. + + ``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 +1080,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() @@ -699,10 +1100,11 @@ async def verify_wba_request( 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: - raise WbaVerificationError("missing or invalid nonce", label=label) + if not valid_wba_nonce(nonce): + raise WbaVerificationError("missing nonce, or not 64 bytes", label=label) + nonce = cast(str, nonce) agent = agents.get(label) if ( not isinstance(agent, Item) @@ -736,31 +1138,31 @@ 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 + # A resolver that cannot answer leaves the request unverified. + raise WbaDiscoveryError("signer key resolution failed", label=label) from exc if ( key is None or canonical_origin(key.agent_url) != origin or thumbprint_key_id(key.jwk) != keyid ): raise WbaVerificationError("key absent from the named origin", label=label) - alg = _verify_crypto( - member, signatures.get(label), key.jwk, _base(method, url, headers, member) - ) + 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,14 +1175,22 @@ 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 != "claimed": raise WbaVerificationError( "replayed nonce" if claim == "replayed" else "replay store capacity exceeded", @@ -791,36 +1201,115 @@ async def verify_wba_request( 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 + + +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. - return tuple(replace(signer, parsed_body=parsed) for signer, _, _ in verified) + 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: ... __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", + "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 + exceeds this checker's limits. Spend-committing calls fail closed in + the authorization layer; other calls proceed. + + Revocations learned from authenticated lists are retained for the + checker's lifetime and never weakened. Admission is bounded globally and + per operator (registrable domain; every tenant of a PSL PRIVATE hosting + suffix counts together). Overflow refuses the offending origin (it stays + ``"stale"``) rather than evicting another origin's revocations. This + state is in memory: public deployments must persist revocation history + durably, so revocations hold across restarts and replicas. """ def __init__( @@ -861,50 +1350,82 @@ def __init__( self._history_fact_count = 0 self._checkers: dict[str, Any] = {} 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(): + 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") + return "stale" 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") + return "stale" 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" @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 +1449,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 +1478,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/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py new file mode 100644 index 000000000..0fc2bb0a6 --- /dev/null +++ b/tests/conformance/signing/test_wba_interop.py @@ -0,0 +1,266 @@ +"""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, + 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 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 valid_wba_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 valid_wba_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..4fba89376 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): @@ -226,7 +252,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 +285,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: @@ -315,6 +342,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 +653,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 +724,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 +748,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/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py new file mode 100644 index 000000000..9ed6ae2ad --- /dev/null +++ b/tests/test_identity_3_3_alignment.py @@ -0,0 +1,1175 @@ +"""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) + with pytest.warns(DeprecationWarning): + 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(), + ) + # Once a list was verified, a later 404 is a failure to read it, never a reset. + published[0], now[0] = True, 61 + assert await checker.status(ORIGIN, jwk["kid"]) == "current" + + +@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(): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + + class Checker: + async def status(self, origin, keyid): + return "revoked" + + from adcp.signing.wba import WbaRevokedKeyError, check_wba_revocation + + signer = VerifiedSigner( + jwk["kid"], "ed25519", "relay", time.time(), agent_url=ORIGIN, profile="web-bot-auth" + ) + with pytest.raises(WbaRevokedKeyError) as revoked: + await check_wba_revocation(Checker(), (signer,)) + assert revoked.value.status == 403 + 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) -> 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) -> 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_bytes", [32, 63, 65]) +async def test_verifier_accepts_exactly_64_byte_nonces(nonce_bytes): + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + body = _tool("get_products") + headers = _signed(private, body, created=1000, nonce=b64url_encode(b"\x07" * nonce_bytes)) + with pytest.raises(WbaVerificationError, match="64 bytes"): + await verify_wba_request( + method="POST", + url=TARGET, + headers=headers, + body=body, + signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), + replay_store=InMemoryReplayStore(), + now=1001, + ) + + +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="relay signer"): + 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="relay signer"): + 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_quota_groups_private_suffix_tenants(): + from datetime import datetime, timezone + + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + start = datetime.now(timezone.utc) + + async def resolve(keyid, *, signer_origin=None): + return ResolvedSignerKey(signer_origin, jwk) + + async def fetch(uri, **kwargs): + return _signed_revocations(private, uri.partition("/.well-known")[0], start=start) + + checker = OriginRevocationChecker( + resolve, fetcher=fetch, max_origins=8, max_origins_per_operator=2 + ) + for tenant in ("a", "b"): + assert await checker.status(f"https://{tenant}.vercel.app", jwk["kid"]) == "current" + # A third free tenant of the same hosting provider shares the quota. + assert await checker.status("https://c.vercel.app", jwk["kid"]) == "stale" + assert await checker.status("https://buyer.other.com", jwk["kid"]) == "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"), + ] diff --git a/tests/test_identity_discovery_cache_security.py b/tests/test_identity_discovery_cache_security.py index 0540da771..c679528f9 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"]) @@ -446,10 +449,10 @@ 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"]) + # Over its operator's quota: refused (stale, so no spend), never a 503 + # on the signature path, and no other origin's state is evicted. + assert await checker.status("https://buyer-3.agency.com", jwk["kid"]) == "stale" + assert await checker.status("https://buyer.other.com", jwk["kid"]) == "current" assert len(checker._checkers) == 3 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_protocol_regressions.py b/tests/test_identity_protocol_regressions.py index f5e5ffaa0..160bf8d13 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)] @@ -389,7 +393,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 +493,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 +548,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 +583,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 +820,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 +890,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..a22a8b353 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 SERVICE_UNAVAILABLE 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 From d78ec22c3165b6ae001bc476259a70af1144765c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 18:42:04 +0000 Subject: [PATCH 03/46] docs(identity): document the layered Web Bot Auth and trust outage rules Rewrite the AdCP 3.3 agent identity migration guide for the final spec decisions: errors split by layer, discovery failures, revocation states, delisting and outage continuation, house-record grants, interop details, and the deliberate differences from the Go and TypeScript SDKs. Refs #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agent-identity-3-3-migration.md | 180 ++++++++++++++++++++------- 1 file changed, 132 insertions(+), 48 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 89419db73..15411881f 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, which Cloudflare's `web-bot-auth` verifier requires), 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. -Serve a signed directory response at `/.well-known/http-message-signatures-directory` on that origin: +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 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. -## Seller verification and binding +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 ```python from adcp.server import RequestSignatureVerification, serve @@ -58,57 +68,118 @@ 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. +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. -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. +### Layers -`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. +Verification and authorization are separate layers, as in the other SDKs. -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** (`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 the binding, re-evaluated against the operator's trust record, then 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. 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. +### Errors are split by layer -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: +- **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 replay-store or discovery capacity. 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. -```python -await records.bind( - verified_signer, - principal_id="principal_123", - bindings=bindings, -) -``` +A correctly signed request with a valid key never gets 403 with `Accept-Signature`. 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. +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. -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. +### Interop -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. +- Nonces must encode exactly 64 bytes, as unpadded base64url or padded standard base64 (Cloudflare's rule). +- `@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. -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. +### Key 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. +`DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. -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. +- 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 exactly `"@authority";req` and `"content-digest"`, 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. -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. +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. -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. +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. -## Trust records and demand grants +### Revocation -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. +Framework WBA verification installs `OriginRevocationChecker` by default. It reads each origin's signed list and reports, through `status(origin, keyid)`: -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. +- **`"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 exceeds the checker's 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"`:** for custom checkers backed by a store that cannot be read: every bound call gets `SERVICE_UNAVAILABLE`. -`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. +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. -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. +Revoked key facts have 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. **Public deployments must persist revocation history durably**, so revocations hold across restarts and replicas. + +## 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`. +- 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) +``` -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 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. + +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 treats new purchases, acquisitions, `activate_signal`, resumes, financial changes, and unknown operations as spend-committing; name changes, pauses, and cancellations can continue under a cached binding. 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` (256) 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. **Public deployments must persist authority history durably.** `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 -> house_domain | None`) that returns the house named by the brand's verified brand.json (a house redirect, or a `house_domain` the house's `brand_refs[]` reciprocates). Without it, 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. 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 +189,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 +201,45 @@ 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 a claim without a brand or operator, fails closed with `PERMISSION_DENIED`. +- `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. -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. +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. -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. +Typed client results preserve `adcp_error`, including its code, recovery guidance, and details. -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. +## Preview types and rollout -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. +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. -`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. +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`. -## Preview types and rollout +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. + +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 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. +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. -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. +`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. Reading a pending application can therefore return `unconfigured` with a relationship without creating a durable principal or authorizing an account. -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. +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. -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. +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 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. -`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. +## Differences from the other SDKs -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. +- **Nonce acceptance.** The verifier accepts exactly 64-byte nonces, as Go does. The TypeScript verifier keeps a 16-byte floor. +- **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. +- **Replay status.** A replayed nonce is 403, as in Go and the #7894 vectors; capacity is 429. +- **Spend classification.** `update_media_buy` and `control_media_buy` are classified by their parameters; Go treats every `update_media_buy` as spend. +- **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. -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. +- Durable stores are application-owned: bindings, revocation history, authority history and replay state are in memory by default. Public deployments need all four. From cb5620018bd0ec963bcdcf84297b2e3e7e571781 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 19:09:24 +0000 Subject: [PATCH 04/46] fix(identity): address protocol and security review of the identity alignment - A reused WBA nonce is 429 without Accept-Signature, as the spec requires; replay_status=403 only reproduces the older #7894 vector. The signer enforces 64-byte nonces too. - Spend classification fails closed: update_media_buy and control_media_buy commit spend unless every change is a rename, pause or cancellation. - Grantees compare as registrable domains, and the house resolver can map the brand to its child-brand id in the house portfolio. - Unsigned requests to required operations get the WBA Accept-Signature challenge, and a web-bot-auth-only verifier answers them and RFC 9421 signatures with 403. - authorities refuses signatures captured for another host, with a boot warning when unset. - Revocation list state is an LRU cache with revoked facts kept separately, so a flood of origins cannot lock new buyers out; concurrent overflow is unavailable. Trust history admits up to 65536 operators. - Seller-side claim failures are PERMISSION_DENIED without details, and grant_absent is reserved for uncovered claims. - Document the breaking experimental API changes, bearer fallback for signed callers, and the missing durable history seams. Refs #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agent-identity-3-3-migration.md | 52 ++- src/adcp/decisioning/trust_grants.py | 30 +- src/adcp/server/signed_requests.py | 140 +++++++- src/adcp/signing/trust.py | 61 ++-- src/adcp/signing/wba.py | 119 +++++-- tests/conformance/signing/test_wba_profile.py | 8 +- tests/test_identity_3_3_alignment.py | 304 ++++++++++++++++-- .../test_identity_discovery_cache_security.py | 11 +- tests/test_identity_protocol_regressions.py | 5 +- tests/test_wba_outage_security.py | 2 +- 10 files changed, 609 insertions(+), 123 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 15411881f..97a1ce952 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -23,7 +23,7 @@ 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 64-byte random nonce (unpadded base64url, which Cloudflare's `web-bot-auth` verifier requires), 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. +`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, which Cloudflare's `web-bot-auth` verifier requires; a caller-supplied `nonce` must also encode exactly 64 bytes), 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. @@ -68,6 +68,8 @@ verification = RequestSignatureVerification( serve(handler, request_signature_verification=verification) ``` +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. + 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. ### Layers @@ -79,7 +81,7 @@ Verification and authorization are separate layers, as in the other SDKs. ### Errors are split by layer -- **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 replay-store or discovery capacity. 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. +- **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`; @@ -87,7 +89,9 @@ Verification and authorization are separate layers, as in the other SDKs. - 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 with a valid key never gets 403 with `Accept-Signature`. 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. +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. + +**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. 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. @@ -103,7 +107,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` `DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. - 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 exactly `"@authority";req` and `"content-digest"`, 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. +- 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. @@ -120,19 +124,19 @@ Framework WBA verification installs `OriginRevocationChecker` by default. It rea - **`"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 exceeds the checker's 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"`:** for custom checkers backed by a store that cannot be read: every bound call gets `SERVICE_UNAVAILABLE`. +- **`"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. -Revoked key facts have 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. **Public deployments must persist revocation history durably**, so revocations hold across restarts and replicas. +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`. +- 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: @@ -156,7 +160,7 @@ A stored binding records seller approval, not permanent operator membership. The - 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 treats new purchases, acquisitions, `activate_signal`, resumes, financial changes, and unknown operations as spend-committing; name changes, pauses, and cancellations can continue under a cached binding. The decision also governs the account grant gate. +`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. @@ -165,7 +169,7 @@ Framework `serve` dispatch projects authorization failures through the existing `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` (256) 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. **Public deployments must persist authority history durably.** +- **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. @@ -173,8 +177,8 @@ Framework `serve` dispatch projects authorization failures through the existing `TrustGrantEvaluator.check(...)` follows L2 §Grants in trust.json: -- **One record authorizes: the house's.** Pass `resolve_house=` (an async `brand_domain -> house_domain | None`) that returns the house named by the brand's verified brand.json (a house redirect, or a `house_domain` the house's `brand_refs[]` reciprocates). Without it, 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. Authority is implicit when `account.operator`, the brand's domain and the signer's operator share one. +- **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. @@ -202,7 +206,7 @@ gate = TrustGrantGate( ``` - 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 a claim without a brand or operator, fails closed with `PERMISSION_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. @@ -223,7 +227,7 @@ For preview `principal.changed` reasons, pass `experimental_agent_identity=True` 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. Reading a pending application can therefore return `unconfigured` with a relationship without creating a durable principal or authorizing an account. +`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. @@ -235,11 +239,25 @@ RFC 9421 configuration emits a `DeprecationWarning` and continues to work throug - **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. -- **Replay status.** A replayed nonce is 403, as in Go and the #7894 vectors; capacity is 429. -- **Spend classification.** `update_media_buy` and `control_media_buy` are classified by their parameters; Go treats every `update_media_buy` as spend. +- **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. +## Breaking changes to the experimental API + +The experimental identity surface changed incompatibly with the previous preview: + +- `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"`. +- 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. + ## Not covered yet - 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. -- Durable stores are application-owned: bindings, revocation history, authority history and replay state are in memory by default. Public deployments need all four. +- 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/src/adcp/decisioning/trust_grants.py b/src/adcp/decisioning/trust_grants.py index 1f16a2392..6c5d67400 100644 --- a/src/adcp/decisioning/trust_grants.py +++ b/src/adcp/decisioning/trust_grants.py @@ -57,12 +57,16 @@ class TrustGrantClaim: ) -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: @@ -87,6 +91,16 @@ def _grant_absent(message: str) -> AdcpError: ) +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( @@ -184,21 +198,23 @@ async def require_account( except AdcpError: raise except Exception as exc: - # Missing seller-owned brand/operator state never authorizes. - raise _grant_absent("Account has no resolvable grant claim") from 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 _grant_absent("Account has no resolvable grant claim") + raise _seller_claim_unavailable() if ( operation is not None and operation not in _MEDIA_BUY_DEFAULT_OPERATIONS and claim.scope == "adcp:media_buying" ): - raise _grant_absent("Operation requires an explicit grant scope") + # The seller did not choose a scope for this operation. + raise _seller_claim_unavailable() await self.require_claim( claim, agent, signing_profile=signing_profile, - non_spend=_cached_binding_may_continue(operation, operation_params, spend_committing), + non_spend=_is_non_spend(operation, operation_params, spend_committing), wba_revocation=wba_revocation, ) diff --git a/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index f79ef8776..6ebe63d7f 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -73,6 +73,7 @@ WbaRevokedKeyError, WbaUnavailableError, WbaVerificationError, + accept_signature_value, check_wba_revocation, uses_web_bot_auth, verify_wba_request, @@ -115,7 +116,14 @@ 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.""" + """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", @@ -146,18 +154,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 ) @@ -270,6 +313,19 @@ class RequestSignatureVerification: 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 @@ -291,10 +347,17 @@ class RequestSignatureVerification: unbound_for: frozenset[str] = frozenset({"get_adcp_capabilities", "get_principal"}) spend_classifier: Callable[[str, Mapping[str, Any]], bool] = is_wba_spend_committing 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): @@ -373,6 +436,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) @@ -484,14 +552,21 @@ def _received_target_uri(scope: Mapping[str, Any]) -> str: if host is None: server = scope.get("server") if isinstance(server, (tuple, list)) and len(server) == 2: - host = f"{server[0]}:{server[1]}" + 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: - path = str(scope.get("root_path", "")) + str(scope.get("path", "")) + # 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] @@ -626,6 +701,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( @@ -633,6 +717,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": @@ -646,6 +731,15 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: 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"), + ) + return try: signer, authorization_error = await self._verify(scope, body, label, posture) @@ -699,6 +793,8 @@ async def _serve_wba( 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)}) @@ -1083,15 +1179,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]: @@ -1278,6 +1381,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/signing/trust.py b/src/adcp/signing/trust.py index 1ebffbec4..7a49da851 100644 --- a/src/adcp/signing/trust.py +++ b/src/adcp/signing/trust.py @@ -52,8 +52,6 @@ class TrustRecordNotFoundError(TrustRecordError): failure (:class:`TrustRecordUnavailableError`), never a removal. """ - operator_domain: str | None = None - class TrustRecordAbsentError(TrustRecordNotFoundError): """404/410 before any successful discovery permits configured legacy fallback.""" @@ -208,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) @@ -364,7 +365,7 @@ 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, @@ -923,7 +924,8 @@ 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. @@ -942,17 +944,32 @@ def _grant_matches( return True -HouseResolver = Callable[[str], Awaitable[str | None]] +@dataclass(frozen=True) +class BrandHouse: + """The house a brand's verified brand.json names, and the brand's id there. + + ``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)`` - returns the verified house domain the brand's brand.json names (a - house redirect, or a ``house_domain`` the house reciprocates); without - the hook, or when it returns ``None``, the brand's own domain is the - house. Grants in any other record are never consulted. + * 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. @@ -1097,10 +1114,15 @@ async def check( house = brand_domain if self._resolve_house is not None: try: - resolved = await self._resolve_house(brand_domain) + resolved = await self._resolve_house(brand_domain, brand_id) except Exception: return _unavailable("brand_house_unavailable") - if resolved is not None: + 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") @@ -1151,11 +1173,9 @@ async def check( grants = brand_record.document.get("grants", []) links: list[list[Mapping[str, Any]]] = [] used_records = [signer_record, brand_record] - if operator == signer_operator: - # The house grants the signer's operator, which is account.operator. - links = [[g for g in grants if _grant_matches(g, grantee=signer_operator, **args)]] - elif operator == brand_operator: - # A self-operated brand (it is account.operator) whose house + 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: @@ -1213,6 +1233,7 @@ async def check( __all__ = [ + "BrandHouse", "GrantRequest", "EvaluatedTrustRecord", "GrantAuthorization", diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index bf2cc2937..7cc7ba103 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -21,7 +21,7 @@ import secrets import time from collections import OrderedDict -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any, Literal, Protocol, cast @@ -552,7 +552,10 @@ 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") @@ -560,9 +563,9 @@ def sign_wba_request( alg = _algorithm(jwk) created = int(time.time()) if created is None else created nonce = _new_nonce() if nonce is None else nonce - if not 22 <= len(nonce) <= 256: + if not valid_wba_nonce(nonce): raise ValueError( - "nonce must carry at least 128 bits of entropy and be at most 256 characters" + "nonce must encode exactly 64 bytes as unpadded base64url or padded base64" ) outgoing = {"Signature-Agent": str(Dictionary({label: Item(origin)}))} components = [Item(c) for c in ("@method", "@target-uri", "@authority")] @@ -1052,6 +1055,8 @@ async def verify_wba_request( 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 every WBA signature and relay coverage; return established origins. @@ -1066,6 +1071,11 @@ async def verify_wba_request( 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 @@ -1097,6 +1107,14 @@ 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: @@ -1146,12 +1164,17 @@ async def verify_wba_request( except Exception as exc: # A resolver that cannot answer leaves the request unverified. raise WbaDiscoveryError("signer key resolution failed", label=label) from exc - if ( - key is None - or canonical_origin(key.agent_url) != origin - or thumbprint_key_id(key.jwk) != keyid - ): + 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) + assert key is not None try: alg = _verify_crypto( member, signatures.get(label), key.jwk, _base(method, url, headers, member) @@ -1191,11 +1214,13 @@ async def verify_wba_request( ) 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 @@ -1300,16 +1325,20 @@ class OriginRevocationChecker: * ``"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 - exceeds this checker's limits. Spend-committing calls fail closed in - the authorization layer; other calls proceed. - - Revocations learned from authenticated lists are retained for the - checker's lifetime and never weakened. Admission is bounded globally and - per operator (registrable domain; every tenant of a PSL PRIVATE hosting - suffix counts together). Overflow refuses the offending origin (it stays - ``"stale"``) rather than evicting another origin's revocations. This - state is in memory: public deployments must persist revocation history - durably, so revocations hold across restarts and replicas. + 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__( @@ -1348,7 +1377,7 @@ 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]] = {} # origin -> (retry_after, served no list) self._failures: OrderedDict[str, tuple[float, bool]] = OrderedDict() @@ -1379,23 +1408,19 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: async with self._lock: checker = self._checkers.get(origin) task = None - if checker is None: + 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) 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: - return "stale" - operator = self._operator(origin) - admitted = sum( - self._operator(admitted_origin) == operator - for admitted_origin in admitted_origins - ) - if admitted >= self._max_origins_per_operator: - return "stale" + 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 @@ -1421,6 +1446,32 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: 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: # Origins without a registrable domain cannot bind through trust.json, diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 4fba89376..57f4abaf6 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -143,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"] @@ -300,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", @@ -311,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(): diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 9ed6ae2ad..97143c1ea 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -338,9 +338,12 @@ async def listed(url, *, allow_private=False): principal_id="p", bindings=OriginBindings(), ) - # Once a list was verified, a later 404 is a failure to read it, never a reset. 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"]) @@ -389,21 +392,27 @@ async def listed(url, *, allow_private=False): async def test_revoked_relay_key_is_refused_like_the_originator(): - private = ed25519.Ed25519PrivateKey.generate() - jwk = public_directory_key(private) + 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" - - from adcp.signing.wba import WbaRevokedKeyError, check_wba_revocation + return "revoked" if keyid == relay.key_id else "current" - signer = VerifiedSigner( - jwk["kid"], "ed25519", "relay", time.time(), agent_url=ORIGIN, profile="web-bot-auth" - ) + assert (await check_wba_revocation(Checker(), (originator,)))[0].revocation == "current" with pytest.raises(WbaRevokedKeyError) as revoked: - await check_wba_revocation(Checker(), (signer,)) + await check_wba_revocation(Checker(), (originator, relay)) assert revoked.value.status == 403 + assert revoked.value.label == "sig2" assert not revoked.value.headers @@ -509,7 +518,7 @@ async def test_only_the_house_record_authorizes(): }, } - async def house(brand_domain: str) -> str | None: + 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) @@ -524,7 +533,7 @@ async def house(brand_domain: str) -> str | None: ) assert not denied.authorized and denied.reason == "grant_absent" - async def broken(brand_domain: str) -> str | None: + 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( @@ -590,7 +599,12 @@ async def test_verifier_accepts_exactly_64_byte_nonces(nonce_bytes): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) body = _tool("get_products") - headers = _signed(private, body, created=1000, nonce=b64url_encode(b"\x07" * nonce_bytes)) + nonce = b64url_encode(b"\x07" * nonce_bytes) + with pytest.raises(ValueError, match="64 bytes"): + _signed(private, body, created=1000, nonce=nonce) + valid = b64url_encode(b"\x07" * 64) + headers = _signed(private, body, created=1000, nonce=valid) + headers["Signature-Input"] = headers["Signature-Input"].replace(valid, nonce) with pytest.raises(WbaVerificationError, match="64 bytes"): await verify_wba_request( method="POST", @@ -834,7 +848,7 @@ async def test_signer_never_strips_another_partys_signature_fields(): private = ed25519.Ed25519PrivateKey.generate() existing = {"Signature-Input": 'sig0=("@method");created=1', "Signature": "sig0=:AAAA:"} - with pytest.raises(ValueError, match="relay signer"): + with pytest.raises(ValueError, match="another party"): sign_wba_request( method="POST", url=TARGET, @@ -859,7 +873,7 @@ async def test_signer_never_strips_another_partys_signature_fields(): supported=True, required_for=["get_products"], profiles=["web-bot-auth"] ), ) - with signing_operation("get_products"), pytest.raises(ValueError, match="relay signer"): + 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() @@ -913,27 +927,70 @@ async def fetch(url, *, allow_private=False): assert not records._foreign_inflight and not records._hosts_inflight -async def test_revocation_quota_groups_private_suffix_tenants(): +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): - return _signed_revocations(private, uri.partition("/.well-known")[0], start=start) + 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=8, max_origins_per_operator=2 + resolve, fetcher=fetch, max_origins=3, max_origins_per_operator=2 ) - for tenant in ("a", "b"): - assert await checker.status(f"https://{tenant}.vercel.app", jwk["kid"]) == "current" - # A third free tenant of the same hosting provider shares the quota. - assert await checker.status("https://c.vercel.app", jwk["kid"]) == "stale" - assert await checker.status("https://buyer.other.com", jwk["kid"]) == "current" + 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" # --------------------------------------------------------------------------- @@ -1173,3 +1230,204 @@ def signed(origin: str, payload: dict[str, Any], extra: dict[str, str]): (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 diff --git a/tests/test_identity_discovery_cache_security.py b/tests/test_identity_discovery_cache_security.py index c679528f9..f1b6a4271 100644 --- a/tests/test_identity_discovery_cache_security.py +++ b/tests/test_identity_discovery_cache_security.py @@ -434,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) @@ -449,10 +449,13 @@ async def fetch(uri, **kwargs): ) for index in range(2): assert not await checker(f"https://buyer-{index}.agency.com", jwk["kid"]) - # Over its operator's quota: refused (stale, so no spend), never a 503 - # on the signature path, and no other origin's state is evicted. - assert await checker.status("https://buyer-3.agency.com", jwk["kid"]) == "stale" 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 "https://buyer.other.com" in checker._checkers + assert sum(origin.endswith("agency.com") for origin in checker._checkers) == 2 assert len(checker._checkers) == 3 diff --git a/tests/test_identity_protocol_regressions.py b/tests/test_identity_protocol_regressions.py index 160bf8d13..8cbcb3395 100644 --- a/tests/test_identity_protocol_regressions.py +++ b/tests/test_identity_protocol_regressions.py @@ -202,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 diff --git a/tests/test_wba_outage_security.py b/tests/test_wba_outage_security.py index a22a8b353..a988aa82f 100644 --- a/tests/test_wba_outage_security.py +++ b/tests/test_wba_outage_security.py @@ -1,4 +1,4 @@ -"""Signer evidence outages are discovery failures, never SERVICE_UNAVAILABLE on the signature path.""" +"""Signer evidence outages are discovery failures, never a 503 on the signature path.""" from __future__ import annotations From 85b569b8629374560e7d6e66ae88b49d62c5ac3e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 19:21:13 +0000 Subject: [PATCH 05/46] test(identity): assert revocation LRU eviction order exactly Compare the admitted origins as an ordered list rather than by substring, which CodeQL reads as URL sanitization. Refs #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_identity_discovery_cache_security.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_identity_discovery_cache_security.py b/tests/test_identity_discovery_cache_security.py index f1b6a4271..99e86003b 100644 --- a/tests/test_identity_discovery_cache_security.py +++ b/tests/test_identity_discovery_cache_security.py @@ -454,9 +454,11 @@ async def fetch(uri, **kwargs): # 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 "https://buyer.other.com" in checker._checkers - assert sum(origin.endswith("agency.com") for origin in checker._checkers) == 2 - assert len(checker._checkers) == 3 + 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(): From 160e2022658d9d51aa355d0216c6e6918a73dd49 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 20:09:03 +0000 Subject: [PATCH 06/46] refactor(identity): explain ignored nonce decode errors Comment the expected decode failures in valid_wba_nonce and give the revocation status protocol an explicit body. Refs #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/signing/wba.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 7cc7ba103..37b58f22b 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -116,11 +116,13 @@ def valid_wba_nonce(nonce: object) -> bool: 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) @@ -1275,7 +1277,8 @@ async def check_wba_revocation( 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: ... + async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: + raise NotImplementedError("report the key's revocation state") __all__ = [ From 6e6c828be32ad2c91a6c41a3539f435969fc45bb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 15 Oct 2026 21:14:09 +0000 Subject: [PATCH 07/46] fix(identity): accept short WBA nonces and inject the binding authorizer - The verifier accepts 22 to 256 character nonces again: 64 bytes is RECOMMENDED for signers and httpsig-02 adds no requirement. The signer still sends 64 bytes by default and accepts the same range from callers. - Binding, trust-record and spend logic move out of the transport middleware behind one injected WbaAuthorizer; OriginBindingGate is the default, derived from the config so dataclasses.replace rebuilds it. - The RFC 9421 DeprecationWarning fires only once the SDK advertises 3.3. - Snapshot adcp.signing and adcp.types.experimental_identity in the public API snapshot, with the PgReplayStore stub resolving like the real class. Refs #1511, #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agent-identity-3-3-migration.md | 14 +- scripts/export_resolution.py | 2 + src/adcp/server/signed_requests.py | 154 +++++------ src/adcp/server/wba_authorization.py | 151 +++++++++++ src/adcp/signing/__init__.py | 4 + src/adcp/signing/_profile_deprecation.py | 21 ++ src/adcp/signing/autosign.py | 8 +- src/adcp/signing/wba.py | 32 ++- tests/conformance/signing/test_wba_interop.py | 7 +- tests/fixtures/public_api_snapshot.json | 239 ++++++++++++++++++ tests/test_identity_3_3_alignment.py | 170 +++++++++++-- 11 files changed, 678 insertions(+), 124 deletions(-) create mode 100644 src/adcp/server/wba_authorization.py create mode 100644 src/adcp/signing/_profile_deprecation.py diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 97a1ce952..e85c64d5c 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -23,7 +23,7 @@ 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 64-byte random nonce (unpadded base64url, which Cloudflare's `web-bot-auth` verifier requires; a caller-supplied `nonce` must also encode exactly 64 bytes), 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. +`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. @@ -77,7 +77,7 @@ A presented `Signature-Agent` or `web-bot-auth` tag selects WBA exclusively, inc Verification and authorization are separate layers, as in the other SDKs. - **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 the binding, re-evaluated against the operator's trust record, then grants. +- **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. ### Errors are split by layer @@ -97,7 +97,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- Nonces must encode exactly 64 bytes, as unpadded base64url or padded standard base64 (Cloudflare's rule). +- 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. @@ -231,11 +231,11 @@ Use `ADCPClient(..., experimental_agent_identity=True)` or the equivalent multi- 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. -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 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. +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. ## Differences from the other SDKs -- **Nonce acceptance.** The verifier accepts exactly 64-byte nonces, as Go does. The TypeScript verifier keeps a 16-byte floor. +- **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. @@ -252,12 +252,16 @@ The experimental identity surface changed incompatibly with the previous preview - `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. +`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. + ## Not covered yet - 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/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/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index 6ebe63d7f..cfb5c11e8 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -82,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 @@ -298,6 +299,13 @@ class RequestSignatureVerification: :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 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 @@ -345,7 +353,11 @@ 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 + 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 @@ -376,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.", @@ -400,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, @@ -953,97 +987,45 @@ def jwks_resolver(keyid: str) -> dict[str, Any] | None: async def _authorize_wba_binding( self, signer: VerifiedSigner, operation: str ) -> tuple[str | None, AdcpError | None]: - """Turn a verified origin into its bound principal, or ``None``. - - 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"`` (the revocation store) 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. - """ - from adcp.signing.canonical import canonical_origin + """Ask the configured :class:`WbaAuthorizer` for the signer's principal.""" + from adcp.decisioning.types import AdcpError - config = self._config - if config.origin_bindings is None or signer.agent_url is None: + authorizer = self._config.effective_wba_authorizer + if authorizer is None: return None, None - origin = canonical_origin(signer.agent_url) try: - principal = await config.origin_bindings(origin) + return await authorizer(signer, operation, self._operation_params(signer)), None + except AdcpError as error: + return None, error except Exception: - logger.exception("origin binding lookup failed") - return None, _service_unavailable("Origin binding lookup is temporarily unavailable") - if principal is None: - return None, None - spend = self._is_spend_committing(signer, operation) - if signer.revocation == "stale": - if spend: - return None, _service_unavailable( - "Origin revocation status is temporarily unavailable" - ) - elif signer.revocation == "unavailable": - return None, _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, None - if config.trust_records is None: - return None, _service_unavailable("Origin binding evaluation is unavailable") - listing = await config.trust_records.check_listing(origin, role=config.binding_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. - from adcp.signing.trust import TrustRecordError, TrustRecordMatchError - - try: - await config.trust_records.validate_snapshots( - (listing.record,), origin_or_domain=origin, role=config.binding_role - ) - except TrustRecordMatchError: - return None, None - except TrustRecordError: - return None, _service_unavailable( - "Operator trust record is temporarily unavailable" - ) - if listing.status == "listed": - return principal, None - if listing.status == "unlisted": - logger.info( - "bound origin is not listed by its operator", - extra={"reason": type(listing.error).__name__}, + logger.exception("Web Bot Auth authorizer failed") + return None, AdcpError( + "PERMISSION_DENIED", + message="Signer origin is not authorized", + recovery="correctable", ) - return None, None - if spend or not listing.within_grace: - return None, _service_unavailable("Operator trust record is temporarily unavailable") - return principal, None - def _is_spend_committing(self, signer: VerifiedSigner, operation: str) -> bool: + 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 - 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 - - try: - _skill, arguments = parse_a2a_jsonrpc_skill(dict(payload), self._message_parser) - except Exception: - return True - if isinstance(arguments, Mapping): - params = arguments - return self._config.spend_classifier(operation, params) + 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 + + 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: + params = self._operation_params(signer) + return self._config.spend_classifier(operation, params if params is not None else None) @staticmethod async def _reject_wba(scope: Any, receive: Any, send: Any, exc: WbaVerificationError) -> None: 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 5f1db16fc..f675a9ef6 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -398,6 +398,10 @@ 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", 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/wba.py b/src/adcp/signing/wba.py index 37b58f22b..3d8535822 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -59,8 +59,9 @@ DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json" ALG_RSA_PSS_SHA512 = "rsa-pss-sha512" -#: A Web Bot Auth nonce is 64 random bytes (architecture draft §4.2.2), the -#: length Cloudflare's ``web-bot-auth`` verifier requires. +#: 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. @@ -103,11 +104,12 @@ def _new_nonce() -> str: return b64url_encode(secrets.token_bytes(WBA_NONCE_BYTES)) -def valid_wba_nonce(nonce: object) -> bool: +def is_64_byte_nonce(nonce: object) -> bool: """Whether ``nonce`` encodes exactly 64 bytes, canonically. - Accepts unpadded base64url and padded standard base64, the two encodings - the architecture draft and Cloudflare's ``web-bot-auth`` library use. + 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 @@ -127,6 +129,21 @@ def valid_wba_nonce(nonce: object) -> bool: 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 Web Bot Auth signature-path failure: an HTTP status and headers. @@ -567,7 +584,7 @@ def sign_wba_request( nonce = _new_nonce() if nonce is None else nonce if not valid_wba_nonce(nonce): raise ValueError( - "nonce must encode exactly 64 bytes as unpadded base64url or padded base64" + "nonce must carry at least 128 bits of entropy and be at most 256 characters" ) outgoing = {"Signature-Agent": str(Dictionary({label: Item(origin)}))} components = [Item(c) for c in ("@method", "@target-uri", "@authority")] @@ -1123,7 +1140,7 @@ async def verify_wba_request( _window(member, now=now, label=label) nonce = member.params.get("nonce") if not valid_wba_nonce(nonce): - raise WbaVerificationError("missing nonce, or not 64 bytes", label=label) + raise WbaVerificationError("missing or invalid nonce", label=label) nonce = cast(str, nonce) agent = agents.get(label) if ( @@ -1305,6 +1322,7 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: "sign_wba_request", "thumbprint_key_id", "uses_web_bot_auth", + "is_64_byte_nonce", "valid_wba_nonce", "verify_directory_response", "verify_wba_request", diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index 0fc2bb0a6..8169c7e8f 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -26,6 +26,7 @@ _algorithm, _base, _verify_crypto, + is_64_byte_nonce, public_directory_key, sign_key_directory, sign_wba_request, @@ -105,7 +106,7 @@ 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 valid_wba_nonce(nonce) + assert nonce.endswith("==") and is_64_byte_nonce(nonce) and valid_wba_nonce(nonce) @pytest.mark.parametrize("path", ["/mcp", "/a%7Eb/mcp/"]) @@ -132,7 +133,7 @@ async def test_python_signed_requests_verify_with_the_target_uri_as_sent(path): ) member = _parse_dictionary(headers["Signature-Input"])["sig1"] # Default nonce: 64 random bytes, unpadded base64url. - assert len(member.params["nonce"]) == 86 and valid_wba_nonce(member.params["nonce"]) + 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 @@ -209,7 +210,7 @@ def test_rsa_pss_appendix_e_vector(): jwk = _rsa_jwk(public) assert thumbprint_key_id(jwk) == "oD0HwocPBSfpNy5W3bpJeyFGY_IQ_YpqxSjQ3Yd-CLA" assert _algorithm(jwk) == ALG_RSA_PSS_SHA512 - assert valid_wba_nonce(APPENDIX_E_NONCE), "padded standard base64 of 64 bytes" + 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"'} 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_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 97143c1ea..389b5aa9e 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -251,10 +251,7 @@ async def fetch(url, *, allow_private=False): async def test_wba_request_to_a_seller_without_wba_is_refused_without_a_challenge(): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) - with pytest.warns(DeprecationWarning): - config = RequestSignatureVerification( - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}) - ) + 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: @@ -594,27 +591,53 @@ async def test_grant_domains_compare_as_registrable_domains(): # --------------------------------------------------------------------------- -@pytest.mark.parametrize("nonce_bytes", [32, 63, 65]) -async def test_verifier_accepts_exactly_64_byte_nonces(nonce_bytes): +@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") - nonce = b64url_encode(b"\x07" * nonce_bytes) - with pytest.raises(ValueError, match="64 bytes"): - _signed(private, body, created=1000, nonce=nonce) - valid = b64url_encode(b"\x07" * 64) - headers = _signed(private, body, created=1000, nonce=valid) - headers["Signature-Input"] = headers["Signature-Input"].replace(valid, nonce) - with pytest.raises(WbaVerificationError, match="64 bytes"): - await verify_wba_request( - method="POST", - url=TARGET, - headers=headers, - body=body, - signer_keys=StaticSignerKeys({ORIGIN: {"keys": [jwk]}}), - replay_store=InMemoryReplayStore(), - now=1001, - ) + 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(): @@ -1431,3 +1454,106 @@ async def test_replayed_wba_nonce_is_429_without_a_challenge(): 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) From 29c7018e4d7f8fa6013003fbd0eecd7acf972c06 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 16 Oct 2026 02:04:00 +0000 Subject: [PATCH 08/46] refactor(signing): take JWS, JWK and Standard Webhooks from maintained libraries The SDK keeps AdCP domain rules and takes these protocols from libraries: - jws.py verifies with joserfc. The AdCP profile stays on top: the alg allow-list, exact typ, no crit, a protected kid, strict JSON in header and payload (joserfc keeps the last duplicate key), EdDSA pinned to Ed25519, and the fetcher's own size cap instead of joserfc's defaults. - keygen.py generates keys and exports public JWKs with joserfc; the curve policy, AdCP JWK members and the CLI stay in the SDK. - standard_webhooks.py signs with the specification's standardwebhooks package. Verification calls Webhook.sign and matches v1 tokens in constant time, because Webhook.verify reads the wall clock with a fixed window and cannot honor the caller's now and tolerance. Adds joserfc and standardwebhooks, and raises the cryptography floor to 45.0.1, which joserfc requires. Refs #1511, #1500 Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 10 +- src/adcp/signing/jws.py | 303 +++++---- src/adcp/signing/keygen.py | 91 ++- src/adcp/signing/standard_webhooks.py | 127 ++-- tests/conformance/signing/test_jws_joserfc.py | 578 ++++++++++++++++++ .../signing/test_keygen_programmatic.py | 74 +++ .../test_revocation_checker_boundary.py | 4 +- .../signing/test_standard_webhooks.py | 153 +++++ 8 files changed, 1138 insertions(+), 202 deletions(-) create mode 100644 tests/conformance/signing/test_jws_joserfc.py 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/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/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/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"") From 9aad42f704831afff767ca144540024c9fa29608 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 17 Oct 2026 00:29:41 +0000 Subject: [PATCH 09/46] refactor(signing): RFC 9421 on http-message-signatures; align Web Bot Auth with adcp#7894 Moves every RFC 9421 layer onto http-message-signatures 2.0.1 and RFC 8941 onto http-sf, replacing the archived http-sfv: - adcp.signing._rfc9421 holds the component resolver that supplies the ;key and ;req parameters (pyauth/http-message-signatures#29) and the AdCP @target-uri/@authority canonicalization, plus thin sign/verify wrappers over HTTPMessageSigner and HTTPMessageVerifier. - The 3.2 request and webhook profiles verify at step 10 through HTTPMessageVerifier and sign through HTTPMessageSigner.sign; the async SigningProvider path takes the library's own base. Error codes and step order are unchanged; every conformance vector passes. - crypto.py dispatch uses the library algorithm classes; Signature, Signature-Input and Content-Digest parse with http-sf, which refuses duplicate keys and parameters through on_duplicate_key. - Governance JWS signs and verifies with joserfc; publisher-pin thumbprints use joserfc; JWS splitting uses joserfc extract_compact. Aligns the experimental Web Bot Auth profile with the final adcontextprotocol/adcp#7894 at e24eeb09: one signature and one Signature-Agent member per request, signed directories only, ed25519 and rsa-pss-sha512 only, a 64-byte nonce, and 429 with Accept-Signature carrying a fresh nonce for a replay. Re-vendors the wba-profile vectors, governance vectors included, with provenance. Refs #1511 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 29 + docs/agent-identity-3-3-migration.md | 41 +- pyproject.toml | 26 +- src/adcp/governance.py | 110 ++- src/adcp/server/signed_requests.py | 11 +- src/adcp/signing/_header_precheck.py | 74 +- src/adcp/signing/_rfc9421.py | 653 ++++++++++++ src/adcp/signing/_structured_fields.py | 177 ---- src/adcp/signing/autosign.py | 6 + src/adcp/signing/canonical.py | 209 ++-- src/adcp/signing/crypto.py | 129 +-- src/adcp/signing/digest.py | 60 +- src/adcp/signing/jws.py | 50 +- src/adcp/signing/publisher_pins.py | 40 +- src/adcp/signing/signer.py | 231 ++--- src/adcp/signing/verifier.py | 73 +- src/adcp/signing/wba.py | 931 ++++++++++-------- src/adcp/signing/webhook_verifier.py | 24 +- tests/conformance/signing/test_crypto.py | 2 +- tests/conformance/signing/test_jws.py | 3 +- .../signing/test_parse_edge_cases.py | 7 +- .../test_revocation_checker_boundary.py | 11 +- .../signing/test_rfc9421_library_contract.py | 167 ++++ .../signing/test_signing_provider.py | 10 +- .../test_structured_field_duplicates.py | 2 +- tests/conformance/signing/test_wba_interop.py | 51 +- tests/conformance/signing/test_wba_profile.py | 354 +++++-- .../signing/test_webhook_rc4_vectors.py | 2 +- .../conformance/vectors/wba-profile/README.md | 15 +- .../vectors/wba-profile/UPSTREAM-README.md | 154 +++ .../vectors/wba-profile/UPSTREAM.json | 25 + .../governance/001-governance-token.json | 46 + ...nce-token-key-not-in-issuer-directory.json | 47 + ...nance-token-from-non-governance-agent.json | 47 + .../conformance/vectors/wba-profile/keys.json | 10 - .../negative/001-missing-nonce.json | 3 +- .../negative/002-replayed-nonce.json | 5 +- .../003-signature-agent-not-covered.json | 3 +- ...on => 004-key-not-in-named-directory.json} | 3 +- .../004-relay-omits-inner-signature.json | 52 - ...son => 005-malformed-signature-agent.json} | 0 .../005-relay-inner-signature-altered.json | 52 - .../negative/006-two-signatures.json | 53 + .../002-relay-request-two-signatures.json | 42 - ...son => 002-signed-directory-response.json} | 0 tests/test_identity_3_3_alignment.py | 157 ++- 46 files changed, 2693 insertions(+), 1504 deletions(-) create mode 100644 src/adcp/signing/_rfc9421.py delete mode 100644 src/adcp/signing/_structured_fields.py create mode 100644 tests/conformance/signing/test_rfc9421_library_contract.py create mode 100644 tests/conformance/vectors/wba-profile/UPSTREAM-README.md create mode 100644 tests/conformance/vectors/wba-profile/UPSTREAM.json create mode 100644 tests/conformance/vectors/wba-profile/governance/001-governance-token.json create mode 100644 tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json create mode 100644 tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json rename tests/conformance/vectors/wba-profile/negative/{006-key-not-in-named-directory.json => 004-key-not-in-named-directory.json} (91%) delete mode 100644 tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json rename tests/conformance/vectors/wba-profile/negative/{007-malformed-signature-agent.json => 005-malformed-signature-agent.json} (100%) delete mode 100644 tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json create mode 100644 tests/conformance/vectors/wba-profile/negative/006-two-signatures.json delete mode 100644 tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json rename tests/conformance/vectors/wba-profile/positive/{003-signed-directory-response.json => 002-signed-directory-response.json} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a8bf90d..fc5b6d2e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,35 @@ streaming budget, so this is its only response-size bound. Refs #1503, adcontextprotocol/adcp#8190, adcontextprotocol/adcp#8200. +* **signing:** RFC 9421 comes from maintained libraries for every profile. + `http-message-signatures` builds the signature base, assembles + `Signature-Input`, and signs and verifies for the AdCP RFC 9421 request and + webhook profiles and for Web Bot Auth, through an SDK component resolver + that adds the `;key` and `;req` parameters and the AdCP `@target-uri` / + `@authority` canonicalization. `http-sf` parses Structured Fields and + refuses duplicate keys and parameters; governance tokens and publisher-pin + thumbprints use `joserfc`. **Dependencies:** the archived `http-sfv` is + replaced by `http-message-signatures>=2.0.1,<3` and `http-sf>=1.4.0,<2`. + Every request-signing and webhook conformance vector passes with the same + error codes. Wire notes: the signer now emits signature parameters in the + library's order (`created`, `keyid`, `alg`, `expires`, `nonce`, `tag`), + which verifiers read from the header; `@signature-params` is the RFC 8941 + serialization of the received member; `Signature-Input`, `Signature` and + `Content-Digest` must parse as a whole; and `parse_compact_jws` requires a + JSON protected header with `alg`. Refs #1511. + +* **identity:** The experimental Web Bot Auth profile follows the final + adcontextprotocol/adcp#7894 (`e24eeb09`): one `web-bot-auth` signature + and one `Signature-Agent` member per request (relays are refused), signed + key directories only, `ed25519` and `rsa-pss-sha512` only (ES256 stays on + the RFC 9421 profile), a 64-byte nonce on every request, and a reused nonce + answered 429 with `Accept-Signature` carrying a fresh nonce. + `verify_wba_governance_issuer` runs the profile's governance-token key and + issuer checks. The removed options and other changes to this experimental + surface are listed in + [the migration guide](docs/agent-identity-3-3-migration.md#breaking-changes-to-the-experimental-api). + Refs #1511. + * **auth:** `BearerTokenAuth.resolve_principal` supports sync/async non-bearer principal resolution on MCP and A2A using the same metadata-only `AuthRequest`. Supplied credential headers always select bearer auth; invalid credentials diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index e85c64d5c..b92c1a4c3 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -1,9 +1,11 @@ # Experimental AdCP 3.3 agent identity -This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile and identity schemas are pinned to the draft commits recorded in `tests/conformance/vectors/wba-profile/README.md` and `src/adcp/signing/_identity_schemas/UPSTREAM.json`. They may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. +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 follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `e24eeb09` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both 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. +RFC 9421 comes from `http-message-signatures` (signature bases, `Signature-Input`, signing and verification, for this profile and for the AdCP RFC 9421 profile alike), Structured Fields from `http-sf`, and JOSE from `joserfc`. The SDK keeps the profile rules on top. + ## 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: @@ -23,13 +25,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 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. +`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), as the profile requires on every signed request, and binds every nonempty body with its digest. A caller-supplied `nonce` must also encode exactly 64 bytes. `@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. +Web Bot Auth signs with `ed25519` or `rsa-pss-sha512`, the profile's two algorithms. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys and RSA keys of 2048 to 8192 bits (signed with `rsa-pss-sha512`, as in the draft's Appendix E vectors). ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 key is refused for Web Bot Auth, and a P-256 key in someone's directory is skipped. -Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. Signing it is RECOMMENDED (WBA Appendix B.1): +Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. The profile requires it signed (WBA Appendix B.1); verifiers refuse an unsigned directory: ```python response = sign_key_directory( @@ -81,7 +83,7 @@ Verification and authorization are separate layers, as in the other SDKs. ### Errors are split by layer -- **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. +- **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, a second signature or `Signature-Agent` member, a nonce that is not 64 bytes, or an unresolvable key directory; 403 without `Accept-Signature` for a revoked key; 429 with `Accept-Signature` carrying a fresh nonce for a reused nonce; and 429 without it for replay-store or discovery capacity. `Accept-Signature` names the profile's components and the parameters `created`, `expires`, `nonce`, `keyid`, `alg` and `tag="web-bot-auth"`. 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`; @@ -89,7 +91,7 @@ Verification and authorization are separate layers, as in the other SDKs. - 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. +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`, sign again with the offered nonce on 429 with `Accept-Signature`, stop using the key on 403 without it, back off on 429 without it or 503. **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. @@ -97,7 +99,8 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- 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. +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. +- **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@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. @@ -106,8 +109,8 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` `DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. -- 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. +- A directory must be signed (WBA Appendix B.1); an unsigned one is a discovery failure. The media type may be `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 (P-256 included) are skipped; private key material fails the directory. +- Every directory signature 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. @@ -231,11 +234,13 @@ Use `ADCPClient(..., experimental_agent_identity=True)` or the equivalent multi- 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. +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. + +**Governance tokens under Web Bot Auth.** `adcp.signing.wba.verify_wba_governance_issuer(token, signer_keys=..., governance_agents=...)` runs the profile's two governance checks (One agent per purpose): the token's `kid` is in the key directory of the `iss` origin and `jku` is that directory's URL (`governance_key_unknown`), and `iss` is the buyer's governance agent (`governance_issuer_not_authorized`), with the JWS, signature and time checks around them. It returns the issuer origin. The rest of the governance checklist (`aud`, `phase`, `sub`, the action binding, revocation, replay) stays with the caller. The `governance/` vectors grade it. 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. ## Differences from the other SDKs -- **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. +- **Nonce encodings.** Exactly 64 bytes, as #7894 requires; Python also accepts the padded standard-base64 spelling Cloudflare's signer emits. Go and TypeScript are aligning to #7894 at the same commit. - **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. @@ -246,7 +251,17 @@ AdCP 3.3 deprecates the RFC 9421 profile; it keeps working through 3.x. `Signing ## Breaking changes to the experimental API -The experimental identity surface changed incompatibly with the previous preview: +The experimental identity surface changed incompatibly with the previous preview. Aligning with the final #7894 profile: + +- `verify_wba_request(replay_status=)` and `RequestSignatureVerification(wba_replay_status=)` are gone: a reused nonce is always 429 with `Accept-Signature` carrying a fresh nonce. +- `DiscoveredSignerKeys(require_signed_directories=)` and `verify_directory_response(require_signed=)` are gone: directories must always be signed. +- Relay signatures are refused (403); a request carries one `web-bot-auth` signature and one `Signature-Agent` member. +- The verifier requires a 64-byte nonce (it accepted 22 to 256 characters), and `sign_wba_request(nonce=)` refuses anything else. +- P-256 keys are refused for Web Bot Auth (`public_directory_key`, `sign_wba_request`, `sign_key_directory`, and `SigningConfig`/`sign_request` with `agent_origin`); `ecdsa-p256-sha256` stays on the RFC 9421 profile. +- `accept_signature_value()` names `alg` and, on a 403, a bare `nonce` parameter; a replay 429 passes a fresh `nonce`. `WbaVerificationError.headers` is computed from its status and label, and the `fresh_nonce=` argument marks a replay answer. +- `adcp.signing.wba` adds `verify_wba_governance_issuer`, `WbaGovernanceError`, `WBA_ALGORITHMS` and `WBA_GOVERNANCE_TYP`. + +From the earlier alignment: - `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. @@ -262,6 +277,6 @@ The experimental identity surface changed incompatibly with the previous preview ## Not covered yet - 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`. +- Accepting the legacy sf-string `Signature-Agent` (a #1511 follow-up). - 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 c230e2eca..18eb304bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,7 +138,20 @@ dependencies = [ # httpx2 2.5.0 requires idna>=3.18. Upper bound at <4 because IDNA # semantics across a major are not contracted. "idna>=3.18,<4", - "http-sfv>=0.9.9,<1.0", + # RFC 9421 HTTP Message Signatures for every signing profile + # (``adcp.signing._rfc9421``): signature bases, ``Signature-Input`` + # assembly, and the ed25519 / ecdsa-p256-sha256 / rsa-pss-sha512 + # algorithms. The SDK supplies a ``component_resolver_class`` and reaches + # two protected subclass hooks (``_build_signature_base``, + # ``_parse_covered_component_ids``); the upper bound and + # tests/conformance/signing/test_rfc9421_library_contract.py guard them. + # Apache-2.0, depends only on cryptography. + "http-message-signatures>=2.0.1,<3", + # RFC 8941 / RFC 9651 Structured Fields (successor to the archived + # http-sfv). 1.4.0 is the tested floor; ``on_duplicate_key`` lets the SDK + # refuse a duplicate dictionary key or parameter instead of keeping the + # last. MIT. + "http-sf>=1.4.0,<2", ] [project.scripts] @@ -377,6 +390,17 @@ ignore_errors = true module = ["psycopg", "psycopg.*", "psycopg_pool", "psycopg_pool.*"] ignore_missing_imports = true +# http-message-signatures ships py.typed but re-exports its public classes +# implicitly and leaves some helpers unannotated. adcp.signing._rfc9421 is the +# one module that calls it. +[[tool.mypy.overrides]] +module = ["http_message_signatures", "http_message_signatures.*"] +implicit_reexport = true + +[[tool.mypy.overrides]] +module = "adcp.signing._rfc9421" +disallow_untyped_calls = false + # jsonschema doesn't ship PEP 561 stubs in the default package; the # ``types-jsonschema`` stub package is optional and we use # :class:`Any`-typed returns across the validator boundary anyway. diff --git a/src/adcp/governance.py b/src/adcp/governance.py index ba38f9b56..1cb417c6a 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -25,22 +25,20 @@ from urllib.parse import urlsplit import rfc8785 +from joserfc import jws +from joserfc.errors import JoseError +from joserfc.jwk import ECKey, OKPKey +from joserfc.jws import JWSRegistry +from joserfc.registry import HeaderParameter, is_bool from pydantic import BaseModel from adcp._version import resolve_adcp_version from adcp.signing.brand_jwks import BrandJsonResolverError, _pick_agent from adcp.signing.canonical import canonicalize_target_uri -from adcp.signing.crypto import ( - ALG_ED25519, - ALG_ES256, - b64url_decode, - b64url_encode, - public_key_from_jwk, - sign_signature_base, - verify_signature, -) +from adcp.signing.crypto import b64url_decode, b64url_encode from adcp.signing.etld import host_from from adcp.signing.jwks import AsyncCachingJwksResolver, AsyncJwksResolver, JwksResolver +from adcp.signing.jws import _AdcpJwsRegistry, _import_jwk from adcp.signing.replay import ReplayStore, supports_atomic_claim from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest @@ -943,8 +941,8 @@ def read_governance_authorization_issuer(token: object) -> str | None: """ if not isinstance(token, str) or not token or len(token) > GOVERNANCE_MAX_TOKEN_BYTES: return None - parts = token.split(".") - if len(parts) != 3 or any(_COMPACT_SEGMENT_RE.fullmatch(part) is None for part in parts): + parts = _compact_segments(token) + if parts is None: return None try: claims = _decode_json_segment(parts[1]) @@ -1088,14 +1086,18 @@ def issue_governance_authorization( "crit": list(GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS), **{name: True for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS}, } - encoded_header = b64url_encode(_compact_json(header)) - encoded_claims = b64url_encode(_compact_json(claims)) - signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") - internal_alg = ALG_ED25519 if alg == "EdDSA" else ALG_ES256 - signature = sign_signature_base( - alg=internal_alg, private_key=private_key, signature_base=signing_input - ) - return f"{encoded_header}.{encoded_claims}.{b64url_encode(signature)}" + try: + key = _jose_key(alg, private_key) + # Sorted header members and the canonical claims bytes keep the token + # byte-identical to the profile's compact JSON; joserfc signs it. + return jws.serialize_compact( + dict(sorted(header.items())), + _compact_json(claims), + key, + registry=_governance_jws_registry(alg), + ) + except JoseError as exc: + raise ValueError(f"governance signing key cannot sign {alg}: {exc}") from exc async def verify_governance_authorization( @@ -1133,8 +1135,8 @@ def reject( return reject("governance_token_invalid", str(exc)) if not isinstance(token, str) or not token or len(token) > GOVERNANCE_MAX_TOKEN_BYTES: return reject("governance_token_invalid", "governance_context is missing or too large") - parts = token.split(".") - if len(parts) != 3 or any(_COMPACT_SEGMENT_RE.fullmatch(part) is None for part in parts): + parts = _compact_segments(token) + if parts is None: return reject("governance_token_invalid", "governance_context is not a compact JWS") try: header = _decode_json_segment(parts[0]) @@ -1197,13 +1199,13 @@ def reject( ): return reject("governance_key_unknown", "governance signing key is not authorized") try: - verified = verify_signature( - alg=ALG_ED25519 if alg == "EdDSA" else ALG_ES256, - public_key=public_key_from_jwk(dict(jwk)), - signature_base=f"{parts[0]}.{parts[1]}".encode("ascii"), - signature=signature, + jws.deserialize_compact( + token, + _import_jwk(_public_jwk(jwk)), + registry=_governance_jws_registry(alg), ) - except (TypeError, ValueError): + verified = True + except (JoseError, TypeError, ValueError): verified = False if not verified: return reject("governance_token_invalid", "governance token signature verification failed") @@ -1441,6 +1443,60 @@ def _finite_number(value: object) -> bool: ) +def _governance_jws_registry(alg: str) -> JWSRegistry: + """The AdCP JWS registry (``jws._AdcpJwsRegistry``) for one governance ``alg``. + + The critical claim markers are registered as boolean protected-header + parameters, so joserfc's ``crit`` check accepts exactly those. + """ + return _AdcpJwsRegistry( + header_registry={ + name: HeaderParameter(f"AdCP critical claim marker: {name}", is_bool) + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS + }, + algorithms=[alg], + strict_check_header=False, + ) + + +def _jose_key(alg: str, key: Any) -> OKPKey | ECKey: + """Import a cryptography private key object for ``alg`` into joserfc. + + Public keys go through ``jws._import_jwk``, which also enforces the + RFC 7518 P-256 coordinate length joserfc leaves open. + """ + if alg == "EdDSA": + return OKPKey.import_key(key) + if alg == "ES256": + return ECKey.import_key(key) + raise ValueError(f"unsupported governance alg {alg!r}") + + +def _public_jwk(jwk: Mapping[str, Any]) -> dict[str, Any]: + """The key members of a resolved JWK; its policy members were checked already.""" + return {name: jwk[name] for name in ("kty", "crv", "x", "y") if name in jwk} + + +def _compact_segments(token: str) -> tuple[str, str, str] | None: + """Split a compact JWS with ``joserfc.jws.extract_compact``, or ``None``.""" + if token.count(".") != 2 or any( + _COMPACT_SEGMENT_RE.fullmatch(part) is None for part in token.split(".") + ): + return None + try: + compact = jws.extract_compact( + token.encode("ascii"), registry=_AdcpJwsRegistry(strict_check_header=False) + ) + except (JoseError, ValueError): + return None + segments = compact.segments + return ( + segments["header"].decode("ascii"), + segments["payload"].decode("ascii"), + segments["signature"].decode("ascii"), + ) + + def _compact_json(value: object) -> bytes: return json.dumps( value, diff --git a/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index cfb5c11e8..260037ade 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -321,8 +321,6 @@ class RequestSignatureVerification: 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 @@ -359,15 +357,12 @@ class RequestSignatureVerification: 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: @@ -827,7 +822,6 @@ async def _serve_wba( 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: @@ -873,9 +867,8 @@ async def _serve_verified_wba( 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] + # The profile verifies exactly one signer per request. + (signer,) = signers principal, authorization_error = await self._authorize_wba_binding(signer, operation) if authorization_error is not None: await self._call_with_signer( diff --git a/src/adcp/signing/_header_precheck.py b/src/adcp/signing/_header_precheck.py index 819b4d677..ac45227ca 100644 --- a/src/adcp/signing/_header_precheck.py +++ b/src/adcp/signing/_header_precheck.py @@ -34,13 +34,12 @@ from collections.abc import Mapping, Sequence -from adcp.signing._structured_fields import reject_dictionary_duplicates +from adcp.signing._rfc9421 import parse_dictionary, translate_legacy_binary from adcp.signing.canonical import ( TargetUriMalformedError, _split_or_reject, host_has_raw_non_ascii, malformed_authority_reason, - split_structured_field, ) #: Headers that must arrive exactly once. Each is either a singleton by RFC @@ -100,20 +99,16 @@ def strict_header_precheck( seen.add(name) for name, value in pairs: - if name in _SINGLE_VALUE_HEADERS and len(split_structured_field(value, ",")) > 1: + if name in _SINGLE_VALUE_HEADERS and _has_top_level_comma(value): return ( f"{name!r} carries more than one value; it is a singleton field " "and a comma-joined value means two were folded together" ) header_name = _DICTIONARY_HEADERS.get(name) if header_name is not None: - reason = _duplicate_dictionary_key_reason(value, header_name) + reason = _dictionary_reason(value, header_name) if reason is not None: return reason - try: - reject_dictionary_duplicates(value) - except ValueError as exc: - return f"{header_name} is malformed: {exc}" return _authority_reason(pairs=pairs, url=url) @@ -141,7 +136,27 @@ def _header_pairs( return pairs -def _duplicate_dictionary_key_reason(value: str, header_name: str) -> str | None: +def _has_top_level_comma(value: str) -> bool: + """Whether a media type carries a comma outside an RFC 9110 quoted-string. + + ``Content-Type`` is not a Structured Field, so this is HTTP field syntax, + not RFC 8941: a ``boundary="a,b"`` parameter is one value, a bare comma is + two values folded together. + """ + quoted = escaped = False + for character in value: + if escaped: + escaped = False + elif quoted and character == "\\": + escaped = True + elif character == '"': + quoted = not quoted + elif character == "," and not quoted: + return True + return False + + +def _dictionary_reason(value: str, header_name: str) -> str | None: """RFC 8941 §3.2: a duplicate dictionary key MUST be rejected, not resolved. "Retaining only the last value" is the other option the RFC allows, and it @@ -149,30 +164,31 @@ def _duplicate_dictionary_key_reason(value: str, header_name: str) -> str | None is the smuggling vector: a proxy appends a second entry with the same label and a weaker covered-component set, the parser keeps the last, and the signature verifies over fewer components than the producer signed. + ``http-sf`` reports every duplicate key and parameter through its + ``on_duplicate_key`` hook, which :func:`parse_dictionary` turns into an + error. - Keys are compared case-folded. RFC 8941 §3.2's `key` production admits - lowercase only, so two keys differing only in case are never two distinct - keys -- one of them is a key spelled illegally. Folding is what makes the - rule hold for `Content-Digest`, whose RFC 9530 §2 algorithm names are - lowercase tokens: comparing `SHA-256` and `sha-256` as written lets the - same algorithm appear twice and the parser-differential this rejects stays - open behind a change of case. + Keys differing only in case (``SHA-256`` beside ``sha-256``) never reach + the duplicate check: RFC 8941 §3.2's ``key`` production is lowercase only, + so the uppercase spelling fails the parse and the field is malformed. """ - keys: set[str] = set() - for entry in split_structured_field(value, ","): - entry = entry.strip() - if not entry: - continue - key = entry.split("=", 1)[0].strip().lower() - if not key: - continue - if key in keys: + try: + try: + parse_dictionary(value) + except ValueError as exc: + if "duplicate" in str(exc): + raise + # The AdCP 3.0/3.1 Base64URL byte-sequence spelling is the + # decoder's call (3.2 refuses it there, still at step 1), not an + # ambiguity: judge the field with those tokens translated. + parse_dictionary(translate_legacy_binary(value)) + except ValueError as exc: + if "duplicate" in str(exc): return ( - f"{header_name} carries duplicate dictionary key {key!r}; RFC 8941 §3.2 " - "requires rejecting rather than resolving, since resolving lets a " - "repeated key downgrade what was signed" + f"{header_name} carries a {exc}; RFC 8941 §3.2 requires rejecting rather " + "than resolving, since resolving lets a repeated key downgrade what was signed" ) - keys.add(key) + return f"{header_name} is malformed: {exc}" return None diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py new file mode 100644 index 000000000..9368c4ecb --- /dev/null +++ b/src/adcp/signing/_rfc9421.py @@ -0,0 +1,653 @@ +"""RFC 9421 and RFC 8941 plumbing shared by every signing profile in the SDK. + +The SDK writes no RFC 9421 or RFC 8941 grammar of its own: + +* ``http-message-signatures`` builds every signature base, assembles + ``Signature-Input``, and signs and verifies with its algorithm classes + (``ed25519``, ``ecdsa-p256-sha256`` and ``rsa-pss-sha512``). +* ``http-sf`` parses and serializes Structured Fields, and reports a + duplicate dictionary key or parameter through ``on_duplicate_key`` so the + SDK can refuse it instead of keeping the last value. + +What stays here is profile policy, expressed through the library's public +extension points: + +* :class:`ComponentResolver` is the ``component_resolver_class`` the signer + and verifier take. It supplies the RFC 9421 ``;key`` (section 2.1.2) and + ``;req`` (section 2.4) component parameters, which + ``http-message-signatures`` 2.0.1 does not implement yet + (pyauth/http-message-signatures#29; the fix is pyauth#30). Once a release + with that fix is out, the ``key``/``req`` branches here can go. +* Its subclasses decide what ``@target-uri`` and ``@authority`` mean: the AdCP + RFC 9421 profile canonicalizes the URL + (:class:`AdcpComponentResolver`), while Web Bot Auth covers the target URI + as received (:class:`ReceivedComponentResolver`). +* :class:`ProfileVerifier` turns off the library's own wall-clock window + check, because every profile checks its window against an injected clock + with its own error codes. + +The signer subclass reaches ``_build_signature_base`` only for the async +(:class:`~adcp.signing.SigningProvider`) path, where the base must exist +before an awaited signature. ``tests/conformance/signing/test_rfc9421_library_contract.py`` +pins the library shapes this module relies on. +""" + +from __future__ import annotations + +import base64 +import binascii +from collections import OrderedDict, UserString +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +import http_sf +from http_message_signatures import ( + HTTPMessageSigner, + HTTPMessageVerifier, + HTTPSignatureComponentResolver, + HTTPSignatureKeyResolver, + InvalidSignature, +) +from http_message_signatures.algorithms import ( + ECDSA_P256_SHA256, + ED25519, + RSA_PSS_SHA512, + HTTPSignatureAlgorithm, +) +from http_message_signatures.exceptions import HTTPMessageSignaturesException +from http_message_signatures.structures import CaseInsensitiveDict +from http_sf.bare_item import parse_bare_item +from http_sf.innerlist import ser_item_or_inner_list +from http_sf.item import parse_item, ser_item +from http_sf.state import ParserState +from http_sf.util import discard_http_ows, discard_ows, parse_key + +#: RFC 9421 algorithm names mapped to the library's algorithm classes. Each +#: profile narrows this to its own allow-list before anything reaches it. +ALGORITHMS: dict[str, type[HTTPSignatureAlgorithm]] = { + algorithm.algorithm_id: algorithm for algorithm in (ED25519, ECDSA_P256_SHA256, RSA_PSS_SHA512) +} + + +# --------------------------------------------------------------------------- +# Structured Fields (RFC 8941 / RFC 9651) via http-sf +# --------------------------------------------------------------------------- + + +_EQUALS, _SEMICOLON, _COMMA = ord("="), ord(";"), ord(",") +_PAREN_OPEN, _PAREN_CLOSE = ord("("), ord(")") + + +def _refuse_duplicate(key: str, context: str) -> None: + where = "dictionary member" if context == "dictionary" else "parameter" + raise ValueError(f"duplicate Structured Field {where} {key!r}") + + +def parse_dictionary(value: str) -> dict[str, Any]: + """Parse an RFC 8941 Dictionary, refusing duplicate keys and parameters. + + RFC 8941 section 4.2.2 lets a parser keep the last of two duplicate + keys. That is how a proxy-appended member downgrades what was signed, so + every duplicate is an error here. Members come back in http-sf's shape: + ``(value, params)`` for an item and ``([items], params)`` for an inner + list. Raises ``ValueError`` for anything that does not parse, including + an empty field. + """ + if not value.isascii(): + raise ValueError("Structured Fields must be ASCII") + try: + parsed = http_sf.parse( + value.encode("ascii"), tltype="dictionary", on_duplicate_key=_refuse_duplicate + ) + except http_sf.StructuredFieldError as exc: + raise ValueError(f"malformed Structured Field: {exc}") from exc + if not isinstance(parsed, dict) or not parsed: + raise ValueError("empty Structured Field dictionary") + return parsed + + +def serialize(value: Any) -> str: + """Serialize a Structured Field value (an http-sf structure).""" + return http_sf.ser(value) + + +def component_identifier(item: Any) -> str: + """The serialized component identifier, e.g. ``"signature-agent";key="sig1"``.""" + return ser_item(item) + + +def is_inner_list(member: Any) -> bool: + return isinstance(member, tuple) and len(member) == 2 and isinstance(member[0], list) + + +def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: + """Whether any Dictionary member carries ``name=expected`` as a member parameter. + + Profile selection must see an explicit marker even where the strict + parse would discard it: in a member a later duplicate overwrites, in a + duplicated parameter, or before a syntax error. Every RFC 8941 parser + keeps the last duplicate or stops at the first error, so this walks the + Dictionary member by member with http-sf's own key, item and bare-item + parsers and looks at each member parameter as it is read. It never + verifies anything; a field it reads still has to pass + :func:`parse_dictionary`. Component parameters inside an inner list and + text inside strings are not member parameters and never match. + """ + # Non-ASCII cannot be RFC 8941; "?" stands in for it so the walk stops there. + state = ParserState(value.encode("ascii", "replace")) + try: + discard_ows(state) + while state.has_data(): + parse_key(state) + if state.has_data() and state.data[state.cursor] == _EQUALS: + state.cursor += 1 + if state.has_data() and state.data[state.cursor] == _PAREN_OPEN: + state.cursor += 1 + while True: + discard_ows(state) + if state.data[state.cursor] == _PAREN_CLOSE: + state.cursor += 1 + break + parse_item(state) + else: + parse_bare_item(state) + while state.has_data() and state.data[state.cursor] == _SEMICOLON: + state.cursor += 1 + discard_ows(state) + key = parse_key(state) + parameter: Any = True + if state.has_data() and state.data[state.cursor] == _EQUALS: + state.cursor += 1 + parameter = parse_bare_item(state) + if key == name and isinstance(parameter, (str, UserString)): + if parameter == expected: + return True + discard_http_ows(state) + if not state.has_data() or state.data[state.cursor] != _COMMA: + break + state.cursor += 1 + discard_http_ows(state) + except (http_sf.StructuredFieldError, IndexError): + # A syntax error ends the walk; a marker already seen was returned. + pass + return False + + +# --------------------------------------------------------------------------- +# AdCP 3.0/3.1 legacy Base64URL sf-binary +# --------------------------------------------------------------------------- + +_BASE64URL_ONLY = frozenset("-_") +_BASE64_ONLY = frozenset("+/") +_LEGACY_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=-_") + + +def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: + """Every ``:...:`` byte-sequence token outside sf-strings, with its member key. + + AdCP 3.0 and 3.1 emitted ``Signature`` and ``Content-Digest`` values in + unpadded Base64URL. No RFC 8941 parser accepts that alphabet (section + 3.3.5 is standard Base64), so the legacy profiles translate those tokens + before parsing. This walker only finds the tokens; the translated field + is then parsed strictly by http-sf. The key is the top-level dictionary + key the token is the value of, or ``None`` for a token in a parameter. + """ + tokens: list[tuple[str | None, str]] = [] + index, length = 0, len(value) + key_start = 0 + while index < length: + char = value[index] + if char == '"': + index += 1 + while index < length and value[index] != '"': + index += 2 if value[index] == "\\" else 1 + index += 1 + continue + if char == ":": + end = value.find(":", index + 1) + if end < 0: + break + owner = None + if index > 0 and value[index - 1] == "=": + candidate = value[key_start : index - 1].strip() + owner = candidate if candidate and ";" not in candidate else None + tokens.append((owner, value[index + 1 : end])) + index = end + 1 + continue + if char == ",": + key_start = index + 1 + index += 1 + return tokens + + +def translate_legacy_binary(value: str) -> str: + """Rewrite Base64URL ``:...:`` tokens as RFC 8941 standard Base64. + + A token in standard Base64 is left alone, so a field already in RFC 8941 + form parses unchanged. Only the legacy profiles call this. + """ + out: list[str] = [] + index, length = 0, len(value) + while index < length: + char = value[index] + if char == '"': + start = index + index += 1 + while index < length and value[index] != '"': + index += 2 if value[index] == "\\" else 1 + index += 1 + out.append(value[start:index]) + continue + if char == ":": + end = value.find(":", index + 1) + if end < 0: + out.append(value[index:]) + break + token = value[index + 1 : end] + if set(token) <= _LEGACY_ALPHABET and (_BASE64URL_ONLY & set(token) or len(token) % 4): + standard = token.replace("-", "+").replace("_", "/").rstrip("=") + try: + raw = base64.b64decode(standard + "=" * (-len(standard) % 4), validate=True) + token = base64.b64encode(raw).decode("ascii") + except (binascii.Error, ValueError): + # Not Base64 in either alphabet: the strict parse refuses it. + pass + out.append(f":{token}:") + index = end + 1 + continue + out.append(char) + index += 1 + return "".join(out) + + +def mixes_base64_alphabets(token: str) -> bool: + """Whether a byte-sequence token uses characters only one alphabet has from each.""" + characters = set(token) + return bool(characters & _BASE64_ONLY) and bool(characters & _BASE64URL_ONLY) + + +# --------------------------------------------------------------------------- +# Messages and component resolution +# --------------------------------------------------------------------------- + + +@dataclass +class Request: + """The request shape ``http-message-signatures`` reads: method, URL, headers. + + Header names are case-insensitive (the library's ``CaseInsensitiveDict``), + so the library finds ``Signature-Input`` however the framework spelled it. + """ + + method: str + url: str + headers: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.headers = CaseInsensitiveDict(dict(self.headers)) + + +@dataclass +class Response: + """A response with the request it answers, for ``;req`` components.""" + + status_code: int + request: Request + headers: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.headers = CaseInsensitiveDict(dict(self.headers)) + + @property + def url(self) -> str: + return self.request.url + + +class ComponentError(HTTPMessageSignaturesException): + """A covered component this profile cannot resolve. + + Raised by the resolvers so a verifier can tell a component that does not + resolve (a malformed request) from a signature that does not verify. + ``status`` is the Web Bot Auth status for the failure. + """ + + def __init__(self, reason: str, *, status: int = 403) -> None: + super().__init__(reason) + self.reason = reason + self.status = status + + +class ComponentResolver(HTTPSignatureComponentResolver): + """The ``component_resolver_class`` every SDK signer and verifier uses. + + Adds what the profiles need on top of the library resolver: + + * ``;key=""`` on a covered Dictionary field resolves to that + member's serialization (RFC 9421 section 2.1.2); + * ``;req`` on a response resolves the component from the request it + answers (section 2.4); + * a covered field's value is trimmed of surrounding whitespace (section + 2.1) and refused when it carries a line break or NUL; + * only the derived components in :attr:`derived_component_names` resolve. + + pyauth/http-message-signatures#30 moves ``key`` and ``req`` into the + library; drop those branches once a release carries it. + """ + + derived_component_names = {"@method", "@target-uri", "@authority"} + + def resolve(self, component_node: Any) -> str: + name = component_node.value + params = dict(component_node.params) + if type(name) is not str: + raise ComponentError("a covered component name must be an sf-string", status=400) + if name != name.lower(): + raise ComponentError("covered component names must be lowercase") + if params.pop("req", False) is True: + if not isinstance(self.message, Response): + raise ComponentError("the req parameter only applies to a response") + request_resolver = type(self)(self.message.request) + return request_resolver._resolve(name, params) + return self._resolve(name, params) + + def _resolve(self, name: str, params: dict[str, Any]) -> str: + if name.startswith("@"): + if params: + raise ComponentError("unsupported derived component parameters") + if name not in self.derived_component_names: + raise ComponentError(f"unsupported derived component {name}") + value: str = getattr(self, "get_" + name[1:].replace("-", "_"))() + return value + raw = self.headers.get(name) + if raw is None: + raise ComponentError(f"covered field {name!r} is missing") + if any(c in raw for c in "\r\n\x00"): + raise ComponentError("a covered field carries a control character", status=400) + if not params: + return str(raw).strip(" \t") + member_key = params.pop("key", None) + if params or type(member_key) is not str: + raise ComponentError("unsupported field component parameters") + try: + dictionary = parse_dictionary(raw) + except ValueError as exc: + raise ComponentError(f"covered field {name!r} is malformed", status=400) from exc + if member_key not in dictionary: + raise ComponentError(f"covered dictionary member {member_key!r} is missing") + return ser_item_or_inner_list(dictionary[member_key]) + + +class AdcpComponentResolver(ComponentResolver): + """The AdCP RFC 9421 profile: ``@target-uri`` and ``@authority`` canonicalized. + + The canonicalization is the AdCP URL algorithm in + :mod:`adcp.signing.canonical`. Its ``TargetUriMalformedError`` propagates + so the verifier reports ``request_target_uri_malformed``. + """ + + def get_target_uri(self) -> str: + from adcp.signing.canonical import canonicalize_target_uri + + return canonicalize_target_uri(self.url) + + def get_authority(self) -> str: + from adcp.signing.canonical import canonicalize_authority + + return canonicalize_authority(self.url) + + +# --------------------------------------------------------------------------- +# Signing and verification on the library +# --------------------------------------------------------------------------- + + +class _Key(HTTPSignatureKeyResolver): + """The one key a profile already selected under its own key policy.""" + + def __init__(self, *, private_key: Any = None, public_key: Any = None) -> None: + self._private_key = private_key + self._public_key = public_key + + def resolve_private_key(self, key_id: str) -> Any: + return self._private_key + + def resolve_public_key(self, key_id: str) -> Any: + return self._public_key + + +class ProfileSigner(HTTPMessageSigner): + """``HTTPMessageSigner`` that can also hand out a signature base. + + ``HTTPMessageSigner.sign`` signs in one step. The async + (:class:`~adcp.signing.SigningProvider`) path awaits the signature, so it + needs the base first, and the public ``build_signature_base`` helper + returns one. Both reuse the library's own base builder through its + protected subclass hooks; the contract test pins them. + """ + + def base( + self, + message: Request | Response, + *, + covered_component_ids: Sequence[str], + params: Mapping[str, Any], + ) -> tuple[bytes, str]: + base, node, _ = self._build_signature_base( + message, + covered_component_ids=self._parse_covered_component_ids(covered_component_ids), + signature_params=dict(params), + ) + return base.encode("utf-8"), str(node) + + +def build_base( + message: Request | Response, + *, + covered_component_ids: Sequence[str], + params: Mapping[str, Any], + resolver: type[ComponentResolver], +) -> bytes: + """The RFC 9421 signature base for these components and parameters. + + ``params`` are serialized in the order given. A component that does not + resolve raises ``ValueError`` (an AdCP canonicalization failure keeps its + own ``ValueError`` subclass). + """ + handler = ProfileSigner( + signature_algorithm=ED25519, key_resolver=_Key(), component_resolver_class=resolver + ) + try: + return handler.base(message, covered_component_ids=covered_component_ids, params=params)[0] + except ComponentError as exc: + raise ValueError(exc.reason) from exc + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + + +def _timestamp(seconds: int) -> datetime: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + + +def sign( + message: Request | Response, + *, + alg: str, + private_key: Any, + key_id: str, + label: str, + created: int, + expires: int | None, + nonce: str | None, + tag: str, + covered_component_ids: Sequence[str], + resolver: type[ComponentResolver], +) -> tuple[str, str]: + """Sign ``message`` with ``HTTPMessageSigner.sign``; return the two fields. + + Returns ``(Signature-Input, Signature)``. ``Signature`` is RFC 8941 + standard Base64; a legacy profile re-encodes it. + """ + signer = ProfileSigner( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(private_key=private_key), + component_resolver_class=resolver, + ) + try: + signer.sign( + message, + key_id=key_id, + created=_timestamp(created), + expires=_timestamp(expires) if expires is not None else None, + nonce=nonce, + label=label, + tag=tag, + covered_component_ids=covered_component_ids, + ) + except ComponentError as exc: + raise ValueError(exc.reason) from exc + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + return message.headers["Signature-Input"], message.headers["Signature"] + + +def signature_base( + message: Request | Response, + *, + alg: str, + key_id: str, + created: int, + expires: int | None, + nonce: str | None, + tag: str, + covered_component_ids: Sequence[str], + resolver: type[ComponentResolver], +) -> tuple[bytes, str]: + """The base :func:`sign` would sign, and the serialized parameters. + + Parameters are added in the order ``HTTPMessageSigner.sign`` adds them, + so a signature over this base is byte-identical to one :func:`sign` + produces. Used where the signature itself is awaited. + """ + params: dict[str, Any] = OrderedDict() + params["created"] = created + params["keyid"] = key_id + params["alg"] = ALGORITHMS[alg].algorithm_id + if expires is not None: + params["expires"] = expires + if nonce: + params["nonce"] = nonce + params["tag"] = tag + handler = ProfileSigner( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(), + component_resolver_class=resolver, + ) + try: + return handler.base(message, covered_component_ids=covered_component_ids, params=params) + except ComponentError as exc: + raise ValueError(exc.reason) from exc + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + + +class ProfileVerifier(HTTPMessageVerifier): + """``HTTPMessageVerifier`` with the window left to the profile. + + The library compares ``created`` and ``expires`` with the real wall + clock and a 5-second skew. Each profile has already checked its window + against the verifier's injected clock, with its own skew and error codes, + before this runs. Unknown signature parameters are the profile's call + too, so the verifier is told which ones the signature carries. + """ + + def validate_created_and_expires(self, sig_input: Any, max_age: Any = None) -> None: + return None + + +class SignatureBaseError(ValueError): + """The signature base could not be built: a component does not resolve.""" + + def __init__(self, reason: str, *, status: int = 403, cause: BaseException | None = None): + super().__init__(reason) + self.reason = reason + self.status = status + self.cause = cause + + +class SignatureMismatchError(ValueError): + """The signature does not verify over the base.""" + + +def verify( + message: Request | Response, + *, + label: str, + tag: str, + alg: str, + public_key: Any, + resolver: type[ComponentResolver], + parameters: Sequence[str] = (), +) -> Mapping[str, str]: + """Verify ``label`` with ``HTTPMessageVerifier``; return the covered components. + + ``message.headers`` must carry the ``Signature-Input`` and ``Signature`` + fields to verify. ``alg`` and ``public_key`` are the profile's choice; + the library confirms the signature names the same ``alg``. Raises + :class:`SignatureBaseError` when a component does not resolve and + :class:`SignatureMismatchError` when the signature does not verify. + """ + verifier = ProfileVerifier( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(public_key=public_key), + component_resolver_class=resolver, + ) + verifier.signature_metadata_parameters = ( + HTTPMessageVerifier.signature_metadata_parameters | set(parameters) + ) + try: + (result,) = verifier.verify(message, expect_tag=tag, expect_label=label) + except InvalidSignature as exc: + cause = exc.__cause__ + if isinstance(cause, ComponentError): + raise SignatureBaseError(cause.reason, status=cause.status, cause=cause) from exc + if cause is not None and not isinstance(cause, HTTPMessageSignaturesException): + # A crypto failure is cryptography's InvalidSignature. Anything + # else (an AdCP canonicalization error, say) came from the base. + from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature + + if not isinstance(cause, CryptoInvalidSignature): + raise SignatureBaseError(str(cause), cause=cause) from exc + raise SignatureMismatchError("signature did not verify") from exc + except HTTPMessageSignaturesException as exc: + # The key does not fit the algorithm: it cannot verify this signature. + raise SignatureMismatchError(str(exc)) from exc + return dict(result.covered_components) + + +__all__ = [ + "ALGORITHMS", + "AdcpComponentResolver", + "ComponentError", + "ComponentResolver", + "ProfileSigner", + "ProfileVerifier", + "Request", + "Response", + "SignatureBaseError", + "SignatureMismatchError", + "build_base", + "component_identifier", + "dictionary_has_parameter", + "is_inner_list", + "legacy_binary_tokens", + "mixes_base64_alphabets", + "parse_dictionary", + "ser_item_or_inner_list", + "serialize", + "sign", + "signature_base", + "translate_legacy_binary", + "verify", +] diff --git a/src/adcp/signing/_structured_fields.py b/src/adcp/signing/_structured_fields.py deleted file mode 100644 index f43d6a352..000000000 --- a/src/adcp/signing/_structured_fields.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Reject Structured Fields duplicates before a parser overwrites their evidence. - -This grammar-aware preflight preserves item and inner-list parameter scopes. -It does not decode values or replace the profile's existing value validation; -in particular, legacy Base64URL compatibility remains the decoder's choice. -""" - -from __future__ import annotations - - -def reject_dictionary_duplicates(value: str) -> None: - """Raise ValueError for duplicate labels/parameters or ambiguous syntax.""" - if not value.isascii(): - raise ValueError("Structured Fields must be ASCII") - _DictionaryScanner(value).scan() - - -def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: - """Observe member parameters without resolving duplicates for profile choice. - - This is never a verification parser. An explicit marker observed before a - syntax failure remains a marker; quoted text and component parameters do - not become dictionary-member parameters. - """ - scanner = _DictionaryScanner(value, reject_duplicates=False) - try: - scanner.scan() - except ValueError: - # A syntax error must not erase an explicit marker already observed. - return _parameters_contain_marker(scanner.member_parameters, name, expected) - return _parameters_contain_marker(scanner.member_parameters, name, expected) - - -def _parameters_contain_marker(parameters: list[tuple[str, str]], name: str, expected: str) -> bool: - from http_sfv.item import Item - - for key, raw in parameters: - if key != name: - continue - try: - item = Item() - item.parse(raw.encode("ascii")) - except (ValueError, UnicodeError): - continue - if item.value == expected: - return True - return False - - -class _DictionaryScanner: - def __init__(self, value: str, *, reject_duplicates: bool = True) -> None: - self.value = value - self.index = 0 - self.reject_duplicates = reject_duplicates - self.member_parameters: list[tuple[str, str]] = [] - - def _ows(self, *, inner: bool = False) -> None: - whitespace = " " if inner else " \t" - while self.index < len(self.value) and self.value[self.index] in whitespace: - self.index += 1 - - def _key(self) -> str: - start = self.index - if start == len(self.value) or self.value[start] not in "abcdefghijklmnopqrstuvwxyz*": - raise ValueError("invalid Structured Field key") - self.index += 1 - while self.index < len(self.value) and self.value[self.index] in ( - "abcdefghijklmnopqrstuvwxyz0123456789_.*-" - ): - self.index += 1 - return self.value[start : self.index] - - def _bare_item(self) -> None: - if self.index == len(self.value): - raise ValueError("missing Structured Field value") - start = self.value[self.index] - if start == "%" and self.value[self.index : self.index + 2] == '%"': - # RFC 9651 Display Strings share quoted delimiter boundaries. - # Their percent encoding is validated by the value parser. - self.index += 1 - start = '"' - if start == '"': - self.index += 1 - while self.index < len(self.value): - character = self.value[self.index] - self.index += 1 - if character == '"': - return - if character == "\\": - if self.index == len(self.value) or self.value[self.index] not in '\\"': - raise ValueError("invalid Structured Field string escape") - self.index += 1 - elif not 0x20 <= ord(character) <= 0x7E: - raise ValueError("invalid Structured Field string") - raise ValueError("unterminated Structured Field string") - if start == ":": - self.index += 1 - while self.index < len(self.value): - character = self.value[self.index] - self.index += 1 - if character == ":": - return - if character not in ( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_-" - ): - raise ValueError("invalid Structured Field binary syntax") - raise ValueError("unterminated Structured Field binary") - initial = self.index - while self.index < len(self.value) and self.value[self.index] not in " \t;,()": - character = self.value[self.index] - if character in '\\"' or not 0x21 <= ord(character) <= 0x7E: - raise ValueError("invalid Structured Field value") - self.index += 1 - if self.index == initial: - raise ValueError("missing Structured Field item") - - def _parameters(self, *, member: bool = False) -> None: - seen: set[str] = set() - while self.index < len(self.value) and self.value[self.index] == ";": - self.index += 1 - self._ows(inner=True) - key = self._key() - if key in seen and self.reject_duplicates: - raise ValueError(f"duplicate Structured Field parameter {key!r}") - seen.add(key) - if self.index < len(self.value) and self.value[self.index] == "=": - self.index += 1 - start = self.index - self._bare_item() - if member: - self.member_parameters.append((key, self.value[start : self.index])) - - def _item(self, *, member: bool = False) -> None: - self._bare_item() - self._parameters(member=member) - - def _member(self) -> None: - if self.index < len(self.value) and self.value[self.index] == "(": - self.index += 1 - while True: - self._ows(inner=True) - if self.index == len(self.value): - raise ValueError("unterminated Structured Field inner list") - if self.value[self.index] == ")": - self.index += 1 - break - self._item() - if self.index == len(self.value) or self.value[self.index] not in " )": - raise ValueError("invalid Structured Field inner-list delimiter") - self._parameters(member=True) - else: - self._item(member=True) - - def scan(self) -> None: - seen: set[str] = set() - self._ows() - if self.index == len(self.value): - raise ValueError("empty Structured Field dictionary") - while self.index < len(self.value): - key = self._key() - if key in seen and self.reject_duplicates: - raise ValueError(f"duplicate Structured Field dictionary member {key!r}") - seen.add(key) - if self.index < len(self.value) and self.value[self.index] == "=": - self.index += 1 - self._member() - else: - self._parameters(member=True) - self._ows() - if self.index == len(self.value): - return - if self.value[self.index] != ",": - raise ValueError("invalid Structured Field dictionary delimiter") - self.index += 1 - self._ows() - if self.index == len(self.value): - raise ValueError("trailing Structured Field dictionary comma") diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index a36bca9aa..890038869 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -18,6 +18,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal +from cryptography.hazmat.primitives.asymmetric import ed25519 from pydantic import RootModel from adcp.signing.constants import DEFAULT_TAG @@ -135,6 +136,11 @@ def __post_init__(self) -> None: from adcp.signing.wba import WBA_TAG, _https_origin, public_directory_key object.__setattr__(self, "agent_origin", _https_origin(self.agent_origin)) + if not isinstance(self.private_key, ed25519.Ed25519PrivateKey): + raise ValueError( + "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " + "RFC 9421 profile algorithm only" + ) 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) diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index 202f8400a..b8aad5b09 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -1,11 +1,18 @@ -"""RFC 9421 canonicalization for the AdCP request-signing profile. - -Implements the canonical signature base per RFC 9421 §2.5 with the covered -components mandated by the AdCP profile (@method, @target-uri, @authority, -content-type, content-digest). URI canonicalization follows RFC 3986 §6.2 -scheme-based normalization plus AdCP-specific rules: query preserved -byte-for-byte, percent-encoding hex uppercased, unreserved chars decoded, -default ports stripped, dot-segments collapsed. +"""AdCP URL canonicalization for the RFC 9421 request-signing profile. + +This module is the AdCP-specific part of the profile: the ``@target-uri`` +and ``@authority`` values the spec defines. URI canonicalization follows +RFC 3986 §6.2 scheme-based normalization plus AdCP-specific rules: query +preserved byte-for-byte, percent-encoding hex uppercased, unreserved chars +decoded, default ports stripped, dot-segments collapsed, hosts converted to +A-labels, and the malformed-authority rejections. + +RFC 9421 itself (the ``Signature-Input`` grammar and the signature base) +comes from ``http-message-signatures`` and ``http-sf``; +:func:`parse_signature_input_header` and :func:`build_signature_base` are +the public entry points onto them, and +:class:`adcp.signing._rfc9421.AdcpComponentResolver` plugs the +canonicalization below into the library. """ from __future__ import annotations @@ -17,7 +24,12 @@ import idna from adcp.signing._idna_canonicalize import canonicalize_host -from adcp.signing._structured_fields import reject_dictionary_duplicates +from adcp.signing._rfc9421 import ( + component_identifier, + is_inner_list, + parse_dictionary, + ser_item_or_inner_list, +) #: Spec code for an authority the AdCP profile requires be rejected rather than #: canonicalized. Declared here rather than imported from ``errors`` so this @@ -56,7 +68,11 @@ def host_has_raw_non_ascii(host: str) -> bool: @dataclass(frozen=True) class SignatureInputLabel: - """A single label in a Signature-Input header (e.g. the `sig1=...` entry).""" + """A single label in a Signature-Input header (e.g. the `sig1=...` entry). + + ``raw_value`` is the member's RFC 8941 serialization, which is the + ``@signature-params`` value the signature base carries. + """ label: str components: tuple[str, ...] @@ -67,33 +83,37 @@ class SignatureInputLabel: def parse_signature_input_header(header_value: str) -> dict[str, SignatureInputLabel]: """Parse a Signature-Input header value into a dict keyed by label. - A Signature-Input header may contain multiple labels separated by commas: - `sig1=(...);..., sig2=(...);...`. The AdCP profile mandates that verifiers - process exactly one label (conventionally `sig1`) and ignore others. + The field is an RFC 8941 Dictionary, parsed by ``http-sf`` with duplicate + keys and parameters refused. The AdCP profile then narrows its shape: + every member is an inner list of component names (sf-strings without + component parameters), and every signature parameter is an sf-string or + an integer. A Signature-Input header may contain several labels; the + profile verifies exactly one (conventionally ``sig1``) and ignores others. """ - reject_dictionary_duplicates(header_value) labels: dict[str, SignatureInputLabel] = {} - for entry in split_structured_field(header_value, ","): - entry = entry.strip() - if not entry: - continue - eq_paren = entry.find("=(") - if eq_paren < 0: - raise ValueError(f"malformed Signature-Input entry: {entry!r}") - label = entry[:eq_paren].strip() - remainder = entry[eq_paren + 1 :] - close = remainder.find(")") - if close < 0: - raise ValueError(f"unterminated component list in label {label!r}") - components_str = remainder[1:close] - params_str = remainder[close + 1 :] - components = tuple(_unquote_component(tok) for tok in components_str.split()) - params = _parse_params(params_str) + for label, member in parse_dictionary(header_value).items(): + if not is_inner_list(member): + raise ValueError(f"malformed Signature-Input entry: {label!r} is not an inner list") + items, member_params = member + components: list[str] = [] + for name, component_params in items: + if type(name) is not str: + raise ValueError(f"component name not quoted: {name!r}") + if component_params: + raise ValueError(f"component {name!r} carries parameters the profile does not use") + components.append(name) + params: dict[str, str | int] = {} + for key, value in member_params.items(): + if type(value) is not str and type(value) is not int: + raise ValueError( + f"signature param {key!r} must be a quoted string or integer, got {value!r}" + ) + params[key] = value labels[label] = SignatureInputLabel( label=label, - components=components, + components=tuple(components), params=params, - raw_value=remainder, + raw_value=ser_item_or_inner_list(member), ) return labels @@ -106,16 +126,20 @@ def build_signature_base( ) -> str: """Build the RFC 9421 signature base string for the AdCP profile. - Lines are joined with a single `\\n` (LF, not CRLF). No trailing newline. - Components appear in the exact order listed in `Signature-Input`, followed - by `@signature-params` as the last line. + ``http-message-signatures`` builds the base: lines joined with a single + LF, no trailing newline, components in ``Signature-Input`` order, then + ``@signature-params``. :class:`AdcpComponentResolver` supplies the AdCP + ``@target-uri`` and ``@authority`` canonicalization. """ - lines: list[str] = [] - for comp in parsed.components: - value = _resolve_component(comp, method, url, headers) - lines.append(f'"{comp}": {value}') - lines.append(f'"@signature-params": {parsed.raw_value}') - return "\n".join(lines) + from adcp.signing._rfc9421 import AdcpComponentResolver, Request, build_base + + base = build_base( + Request(method=method, url=url, headers=dict(headers)), + covered_component_ids=[component_identifier((name, {})) for name in parsed.components], + params=parsed.params, + resolver=AdcpComponentResolver, + ) + return base.decode("utf-8") def canonicalize_target_uri(url: str) -> str: @@ -186,21 +210,6 @@ def _split_or_reject(url: str) -> SplitResult: _UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") -def _resolve_component(comp: str, method: str, url: str, headers: Mapping[str, str]) -> str: - if comp == "@method": - return method.upper() - if comp == "@target-uri": - return canonicalize_target_uri(url) - if comp == "@authority": - return canonicalize_authority(url) - if comp.startswith("@"): - raise ValueError(f"unsupported derived component for AdCP profile: {comp}") - value = _lookup(headers, comp.lower()) - if value is None: - raise ValueError(f"missing header for covered component: {comp}") - return value.strip() - - def _lookup(headers: Mapping[str, str], name_lower: str) -> str | None: for k, v in headers.items(): if k.lower() == name_lower: @@ -417,91 +426,3 @@ def _normalize_pct(s: str) -> str: out.append(c) i += 1 return "".join(out) - - -def split_structured_field(s: str, sep: str) -> list[str]: - """Split on `sep` occurrences that are outside RFC 8941 sf-string quotes and parens. - - sf-string escapes per RFC 8941 §3.3.3 are `\\\\` and `\\"` only; the state - machine tracks an `esc` flag so that `\\\\"` closes the quoted span (the - backslash escapes itself, the following quote is unescaped). - """ - out: list[str] = [] - depth = 0 - in_q = False - esc = False - start = 0 - for i, c in enumerate(s): - if in_q: - if esc: - esc = False - elif c == "\\": - esc = True - elif c == '"': - in_q = False - elif c == '"': - in_q = True - elif c == "(": - depth += 1 - elif c == ")": - depth -= 1 - elif c == sep and depth == 0: - out.append(s[start:i]) - start = i + 1 - out.append(s[start:]) - return out - - -def _unquote_component(token: str) -> str: - t = token.strip() - if len(t) >= 2 and t[0] == '"' and t[-1] == '"': - return t[1:-1] - raise ValueError(f"component name not quoted: {token!r}") - - -def _parse_params(params_str: str) -> dict[str, str | int]: - out: dict[str, str | int] = {} - s = params_str.strip() - if not s: - return out - if s.startswith(";"): - s = s[1:] - for part in split_structured_field(s, ";"): - part = part.strip() - if not part: - continue - k, eq, v = part.partition("=") - k = k.strip() - v = v.strip() - if not k: - raise ValueError(f"signature param with empty name: {part!r}") - if not eq or not v: - raise ValueError(f"signature param {k!r} has empty value") - if v.startswith('"'): - if not (v.endswith('"') and len(v) >= 2): - raise ValueError(f"signature param {k!r} has unterminated quoted value") - out[k] = _unescape_sf_string(v[1:-1]) - else: - try: - out[k] = int(v) - except ValueError as exc: - raise ValueError( - f"signature param {k!r} must be a quoted string or integer, got {v!r}" - ) from exc - return out - - -def _unescape_sf_string(s: str) -> str: - """Unescape RFC 8941 §3.3.3 sf-string contents (only `\\\\` and `\\"`).""" - out: list[str] = [] - i = 0 - n = len(s) - while i < n: - c = s[i] - if c == "\\" and i + 1 < n and s[i + 1] in ("\\", '"'): - out.append(s[i + 1]) - i += 2 - else: - out.append(c) - i += 1 - return "".join(out) diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index 075bec789..e08d0669e 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -1,12 +1,14 @@ -"""Crypto primitives for the AdCP request-signing profile. +"""Key handling and signature primitives for the AdCP request-signing profile. Supported algorithms per RFC 9421 profile: - `ed25519` (EdDSA over Curve25519) - `ecdsa-p256-sha256` (ES256) -ES256 signatures use IEEE P1363 (r||s, 64 bytes) per RFC 9421 §3.3.2. The -`cryptography` library emits/accepts DER by default, so sign() and verify() -convert at the boundary. +Signing and verification are ``http-message-signatures``' algorithm classes, +which also convert ES256 to and from the IEEE P1363 ``r || s`` form RFC 9421 +§3.3.2 requires. The ``Signature`` field is parsed and serialized by +``http-sf``. What stays here is AdCP key policy: the JWK shapes the profile +accepts and the PEM loader. Encrypted PEM support (passphrase-protected private keys) is tracked in #191. """ @@ -14,19 +16,14 @@ from __future__ import annotations import base64 -import binascii from typing import Any from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 -from cryptography.hazmat.primitives.asymmetric.utils import ( - decode_dss_signature, - encode_dss_signature, -) +from http_message_signatures.exceptions import HTTPMessageSignaturesException -from adcp.signing._structured_fields import reject_dictionary_duplicates -from adcp.signing.canonical import split_structured_field +from adcp.signing._rfc9421 import ALGORITHMS, parse_dictionary, serialize, translate_legacy_binary PublicKey = ed25519.Ed25519PublicKey | ec.EllipticCurvePublicKey PrivateKey = ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey @@ -171,28 +168,19 @@ def verify_signature( signature_base: bytes, signature: bytes, ) -> bool: - """Return True iff `signature` is valid over `signature_base` for `alg`.""" + """Return True iff `signature` is valid over `signature_base` for `alg`. + + The check is ``http-message-signatures``' algorithm class for ``alg`` + (ES256 takes the RFC 9421 §3.3.2 ``r || s`` form). A key of the wrong + type for ``alg`` cannot verify, so it returns False. + """ if alg not in ALLOWED_ALGS: raise ValueError(f"unsupported alg: {alg}") try: - if alg == ALG_ED25519: - if not isinstance(public_key, ed25519.Ed25519PublicKey): - return False - public_key.verify(signature, signature_base) - return True - if alg == ALG_ES256: - if not isinstance(public_key, ec.EllipticCurvePublicKey): - return False - if len(signature) != 64: - return False - r = int.from_bytes(signature[:32], "big") - s = int.from_bytes(signature[32:], "big") - der = encode_dss_signature(r, s) - public_key.verify(der, signature_base, ec.ECDSA(hashes.SHA256())) - return True - except InvalidSignature: + ALGORITHMS[alg](public_key=public_key).verify(signature=signature, message=signature_base) + except (InvalidSignature, HTTPMessageSignaturesException): return False - return False + return True def sign_signature_base( @@ -202,17 +190,15 @@ def sign_signature_base( signature_base: bytes, ) -> bytes: """Produce a signature over the base. ES256 returns r||s (64 bytes).""" - if alg == ALG_ED25519: - if not isinstance(private_key, ed25519.Ed25519PrivateKey): - raise ValueError("alg/private_key mismatch: expected Ed25519 key") - return private_key.sign(signature_base) - if alg == ALG_ES256: - if not isinstance(private_key, ec.EllipticCurvePrivateKey): - raise ValueError("alg/private_key mismatch: expected EC P-256 key") - der = private_key.sign(signature_base, ec.ECDSA(hashes.SHA256())) - r, s = decode_dss_signature(der) - return r.to_bytes(32, "big") + s.to_bytes(32, "big") - raise ValueError(f"unsupported alg: {alg}") + if alg not in ALLOWED_ALGS: + raise ValueError(f"unsupported alg: {alg}") + try: + signer = ALGORITHMS[alg](private_key=private_key) + except HTTPMessageSignaturesException as exc: + expected = "Ed25519 key" if alg == ALG_ED25519 else "EC P-256 key" + raise ValueError(f"alg/private_key mismatch: expected {expected}") from exc + signature: bytes = signer.sign(signature_base) + return signature def extract_signature_bytes( @@ -223,35 +209,24 @@ def extract_signature_bytes( ) -> bytes: """Parse a Signature header value and return the raw signature bytes for `label`. - The value is an sf-dictionary: `sig1=::[, sig2=::]`. - RFC 8941 specifies standard padded Base64. AdCP 3.0/3.1 compatibility - permits the historical Base64URL spelling; 3.2 callers disable that - fallback so malformed Structured Fields fail at verifier step 1. + The value is an RFC 8941 Dictionary parsed by ``http-sf``, with duplicate + labels and parameters refused. RFC 8941 byte sequences are standard + padded Base64. AdCP 3.0/3.1 compatibility permits the historical + Base64URL spelling, which is translated before the strict parse; 3.2 + callers disable that so a malformed field fails at verifier step 1. """ - reject_dictionary_duplicates(signature_header) - for entry in split_structured_field(signature_header, ","): - entry = entry.strip() - if not entry: - continue - eq = entry.find("=") - if eq < 0: - continue - name = entry[:eq].strip() - value = entry[eq + 1 :].strip() - if name != label: - continue - if not (value.startswith(":") and value.endswith(":")): - raise ValueError(f"Signature value for {label!r} is not an sf-binary: {value!r}") - inner = value[1:-1] - try: - return base64.b64decode(inner, validate=True) - except (ValueError, binascii.Error) as exc: - if not allow_legacy_base64url: - raise ValueError( - f"Signature value for {label!r} is not RFC 8941 standard Base64" - ) from exc - return b64url_decode(inner) - raise KeyError(f"label not found in Signature header: {label}") + try: + members = parse_dictionary(signature_header) + except ValueError: + if not allow_legacy_base64url: + raise + members = parse_dictionary(translate_legacy_binary(signature_header)) + if label not in members: + raise KeyError(f"label not found in Signature header: {label}") + value, _params = members[label] + if not isinstance(value, bytes): + raise ValueError(f"Signature value for {label!r} is not an sf-binary") + return value def format_signature_header( @@ -260,10 +235,12 @@ def format_signature_header( *, use_legacy_base64url: bool = False, ) -> str: - """Produce a Signature header value for a single-label signature.""" - encoded = ( - b64url_encode(signature) - if use_legacy_base64url - else base64.b64encode(signature).decode("ascii") - ) - return f"{label}=:{encoded}:" + """Produce a Signature header value for a single-label signature. + + RFC 8941 serialization comes from ``http-sf``. The AdCP 3.0/3.1 legacy + spelling (unpadded Base64URL) is not RFC 8941 and is formatted here. + """ + if use_legacy_base64url: + serialize({label: (True, {})}) # validates the label as an RFC 8941 key + return f"{label}=:{b64url_encode(signature)}:" + return serialize({label: (signature, {})}) diff --git a/src/adcp/signing/digest.py b/src/adcp/signing/digest.py index e1b2ee958..84fc99a00 100644 --- a/src/adcp/signing/digest.py +++ b/src/adcp/signing/digest.py @@ -1,55 +1,39 @@ """Content-Digest (RFC 9530) support for the AdCP request-signing profile. -Only `sha-256` is required. The header value is an RFC 8941 sf-dictionary: -`sha-256=::[, sha-512=::]`. A digest matches when the sha-256 -entry's decoded bytes equal `sha256(body)`. +Only `sha-256` is required. The header value is an RFC 8941 Dictionary +(`sha-256=::[, sha-512=::]`), parsed by ``http-sf`` with +duplicate keys refused. A digest matches when the sha-256 entry's decoded +bytes equal `sha256(body)`. """ from __future__ import annotations -import base64 -import binascii import hashlib +import hmac -from adcp.signing.canonical import split_structured_field +from adcp.signing._rfc9421 import parse_dictionary, serialize, translate_legacy_binary def compute_content_digest_sha256(body: bytes) -> str: """Return the Content-Digest header value for a body (sha-256 only).""" - h = hashlib.sha256(body).digest() - return f"sha-256=:{base64.b64encode(h).decode('ascii')}:" + return serialize({"sha-256": (hashlib.sha256(body).digest(), {})}) def content_digest_matches(header_value: str, body: bytes) -> bool: - """Return True iff the sha-256 entry in the header matches the body.""" - expected = hashlib.sha256(body).digest() - for entry in split_structured_field(header_value, ","): - entry = entry.strip() - eq = entry.find("=") - if eq < 0: - continue - algo = entry[:eq].strip().lower() - if algo != "sha-256": - continue - val = entry[eq + 1 :].strip() - if not (val.startswith(":") and val.endswith(":")): - return False - b64 = val[1:-1] - received = _decode_sf_binary(b64) - if received is None: - return False - return received == expected - return False - + """Return True iff the sha-256 entry in the header matches the body. -def _decode_sf_binary(b64: str) -> bytes | None: - """Decode an sf-binary payload. Standard base64 per RFC 8941; also tolerate base64url.""" + RFC 8941 byte sequences are standard Base64. The Base64URL spelling some + AdCP 3.0/3.1 senders emitted is still accepted: a field that does not + parse as written is translated and parsed strictly once more. + """ try: - return base64.b64decode(b64, validate=True) - except (ValueError, binascii.Error): - pass - try: - pad = "=" * (-len(b64) % 4) - return base64.urlsafe_b64decode(b64 + pad) - except (ValueError, binascii.Error): - return None + members = parse_dictionary(header_value) + except ValueError: + try: + members = parse_dictionary(translate_legacy_binary(header_value)) + except ValueError: + return False + entry = members.get("sha-256") + if entry is None or not isinstance(entry[0], bytes): + return False + return hmac.compare_digest(entry[0], hashlib.sha256(body).digest()) diff --git a/src/adcp/signing/jws.py b/src/adcp/signing/jws.py index 649ce00ee..cf537cf7f 100644 --- a/src/adcp/signing/jws.py +++ b/src/adcp/signing/jws.py @@ -141,12 +141,18 @@ def _decode_json_object(raw: bytes, what: str) -> dict[str, Any]: return value +def _split_registry() -> _AdcpJwsRegistry: + # Splitting decodes the protected header and checks segment sizes only; + # the profile's alg allow-list is applied by _validate_header afterwards. + return _AdcpJwsRegistry(strict_check_header=False) + + 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 checks the exact bytes the signer - signed. + ``joserfc.jws.extract_compact`` does the split. The header and payload + come back 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") @@ -155,21 +161,31 @@ def parse_compact_jws(token: str) -> tuple[str, str, bytes]: raise JwsMalformedError( f"compact JWS must have exactly 3 dot-separated segments, got {len(parts)}" ) - b64_header, b64_payload, b64_signature = parts - if not b64_header or not b64_payload or not b64_signature: + if "" in parts: raise JwsMalformedError("compact JWS has an empty segment") try: - signature = _b64url_decode(b64_signature) + compact = jws.extract_compact(token.encode("ascii"), registry=_split_registry()) + except (JoseError, UnicodeError, ValueError) as exc: + raise JwsMalformedError( + f"compact JWS protected header is not valid base64url JSON: {exc}" + ) from exc + try: + signature = _b64url_decode(compact.segments["signature"].decode("ascii")) except ValueError as exc: raise JwsMalformedError(f"compact JWS signature is not valid base64url: {exc}") from exc - return b64_header, b64_payload, signature + return ( + compact.segments["header"].decode("ascii"), + compact.segments["payload"].decode("ascii"), + signature, + ) def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: """Extract the single signature from a JWS general JSON serialization. - Returns ``(b64_header, b64_payload, signature_bytes)``. A single operator - key signs an AdCP revocation list, so this profile treats a list with more + ``joserfc.jws.extract_compact`` does the parse (see below). 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. """ @@ -188,18 +204,14 @@ def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: entry = signatures[0] if not isinstance(entry, dict): raise JwsMalformedError("JWS signature entry must be an object") - b64_header = entry.get("protected") - b64_signature = entry.get("signature") - b64_payload = doc["payload"] - if not isinstance(b64_header, str) or not isinstance(b64_signature, str): + if not isinstance(entry.get("protected"), str) or not isinstance(entry.get("signature"), str): raise JwsMalformedError("JWS signature entry missing 'protected' or 'signature'") - if not isinstance(b64_payload, str): + if not isinstance(doc["payload"], str): raise JwsMalformedError("JWS 'payload' must be a base64url string") - try: - 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 + # One signature with a protected header is the compact serialization in + # another shape (RFC 7515 section 7.2.1), so joserfc's compact extractor + # splits it with the same checks. + return parse_compact_jws(f"{entry['protected']}.{doc['payload']}.{entry['signature']}") def _validate_header( diff --git a/src/adcp/signing/publisher_pins.py b/src/adcp/signing/publisher_pins.py index 79772e362..c5e807199 100644 --- a/src/adcp/signing/publisher_pins.py +++ b/src/adcp/signing/publisher_pins.py @@ -2,26 +2,44 @@ from __future__ import annotations -import hashlib -import json from collections.abc import Mapping, Sequence from datetime import datetime from typing import Any +from joserfc.errors import JoseError +from joserfc.jwk import ECKey, OKPKey, RSAKey +from joserfc.util import urlsafe_b64decode + PublisherPins = Mapping[str, Sequence[Mapping[str, Any]] | None] +#: The public members RFC 7638 hashes, per key type. A key missing one (or +#: carrying a non-string one) has no thumbprint and matches nothing. +_THUMBPRINT_MEMBERS: dict[ + str, tuple[type[OKPKey] | type[ECKey] | type[RSAKey], tuple[str, ...]] +] = { + "OKP": (OKPKey, ("crv", "x")), + "EC": (ECKey, ("crv", "x", "y")), + "RSA": (RSAKey, ("e", "n")), +} + def jwk_thumbprint(key: Mapping[str, Any]) -> bytes | None: - """Return the RFC 7638 digest; incomplete public keys match nothing.""" - members = { - "OKP": ("crv", "kty", "x"), - "EC": ("crv", "kty", "x", "y"), - "RSA": ("e", "kty", "n"), - }.get(str(key.get("kty"))) - if members is None or any(not isinstance(key.get(m), str) or not key[m] for m in members): + """Return the RFC 7638 digest (``joserfc``); incomplete public keys match nothing. + + Only the public members go to ``joserfc``: a pin or directory entry is a + public key, and the thumbprint never depends on anything else. + """ + shape = _THUMBPRINT_MEMBERS.get(str(key.get("kty"))) + if shape is None: + return None + key_type, members = shape + if any(not isinstance(key.get(m), str) or not key[m] for m in members): + return None + try: + imported = key_type.import_key({"kty": key["kty"], **{m: key[m] for m in members}}) + return urlsafe_b64decode(imported.thumbprint().encode("ascii")) + except (JoseError, ValueError, TypeError): return None - canonical = json.dumps({m: key[m] for m in members}, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical.encode("utf-8")).digest() def matches_publisher_pins(key: Mapping[str, Any], pins: PublisherPins, *, now: float) -> bool: diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index 63478ee2e..47c6954cc 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -2,6 +2,9 @@ Produces the `Signature-Input`, `Signature`, and (optionally) `Content-Digest` headers for a request, per the verifier checklist the other side will run. +``http-message-signatures`` builds the signature base and serializes +``Signature-Input``; this module applies the profile's choices (covered +components, window, nonce, tag, and the 3.0/3.1 Base64URL ``Signature``). Two entry points share the same canonicalization spine: @@ -21,11 +24,11 @@ from dataclasses import dataclass from typing import Literal -from adcp.signing.canonical import ( - SignatureInputLabel, - _lookup, - build_signature_base, -) +from cryptography.hazmat.primitives.asymmetric import ed25519 +from http_message_signatures.exceptions import HTTPMessageSignaturesException + +from adcp.signing import _rfc9421 +from adcp.signing.canonical import _lookup from adcp.signing.constants import ( DEFAULT_EXPIRES_IN_SECONDS, DEFAULT_TAG, @@ -39,8 +42,8 @@ ALLOWED_ALGS, PrivateKey, b64url_encode, + extract_signature_bytes, format_signature_header, - sign_signature_base, ) from adcp.signing.digest import compute_content_digest_sha256 from adcp.signing.provider import SigningProvider @@ -67,72 +70,19 @@ def as_dict(self) -> dict[str, str]: return headers -_SF_STRING_ALLOWED: frozenset[str] = frozenset(chr(c) for c in range(0x20, 0x7F)) - -# RFC 8941 §3.1.2 key grammar: ( lcalpha / "*" ) *( lcalpha / DIGIT / "_" / "-" / "." / "*" ) -_SF_KEY_HEAD_ALLOWED: frozenset[str] = frozenset("abcdefghijklmnopqrstuvwxyz*") -_SF_KEY_TAIL_ALLOWED: frozenset[str] = frozenset("abcdefghijklmnopqrstuvwxyz0123456789_-.*") - - -def _validate_sf_key(value: str, *, field: str) -> str: - """Validate ``value`` as an RFC 8941 §3.1.2 sf-key (token). - - Sig-labels are emitted unquoted in both ``Signature-Input`` and - ``Signature`` headers, so they cannot use sf-string escaping — - they must be valid tokens. Anything outside the §3.1.2 grammar - would either inject extra header bytes (CRLF) or produce a - label conformant verifiers parse differently. - """ - if not value: - raise ValueError(f"{field} must be a non-empty RFC 8941 sf-key") - if value[0] not in _SF_KEY_HEAD_ALLOWED: - raise ValueError( - f"{field}={value!r} is not a valid RFC 8941 sf-key — must start with " - "a lowercase letter or '*'" - ) - bad = next((c for c in value[1:] if c not in _SF_KEY_TAIL_ALLOWED), None) - if bad is not None: - raise ValueError( - f"{field}={value!r} is not a valid RFC 8941 sf-key — character " - f"{bad!r} (codepoint {ord(bad):#06x}) is not allowed; " - "only [a-z0-9_-.*] permitted after the first character" - ) - return value - - -def _escape_sf_string(value: str, *, field: str) -> str: - """Escape ``value`` for embedding in an RFC 8941 §3.3.3 sf-string. - - The §3.3.3 grammar permits only printable ASCII 0x20-0x7E; ``"`` - and ``\\`` require escaping (and are the only escapes). Control - bytes and non-ASCII have no representation at all and are rejected - here — passing one through would emit a header line that conformant - verifiers parse differently from this serializer (a parser-divergence - bug class) and, in the CRLF case, can also turn into HTTP header - injection at non-httpx integrators that don't sanitize embedded - line terminators. - """ - bad = next((c for c in value if c not in _SF_STRING_ALLOWED), None) - if bad is not None: - raise ValueError( - f"{field} contains character {bad!r} (codepoint {ord(bad):#06x}) " - "not allowed in RFC 8941 sf-string — only printable ASCII 0x20-0x7E " - "may appear in keyid, nonce, or tag values" - ) - return value.replace("\\", "\\\\").replace('"', '\\"') - - @dataclass(frozen=True) -class _PreparedSignature: - """Result of canonicalizing a request — everything except the raw signature.""" - - base: bytes - raw_value: str - label: str +class _PreparedRequest: + """Everything about a request to sign except the signature itself.""" + + message: _rfc9421.Request + covered_component_ids: tuple[str, ...] + created: int + expires: int + nonce: str content_digest_value: str | None -def _prepare_signature( +def _prepare_request( *, method: str, url: str, @@ -146,13 +96,15 @@ def _prepare_signature( nonce: str | None, tag: str, label: str, -) -> _PreparedSignature: - """Canonicalize a request into a signable RFC 9421 base. +) -> _PreparedRequest: + """Apply the AdCP profile's signer rules; the library does the RFC 9421 work. Pure: takes only the request shape and the key metadata that lands in - ``Signature-Input``. The actual key material (private key OR - SigningProvider) is applied separately so the sync and async signers - produce byte-identical bases. + ``Signature-Input``. The key material (private key or SigningProvider) is + applied separately, and the sync and async signers produce byte-identical + headers. Values that RFC 8941 cannot carry (a control character in + ``key_id``, a label outside the key grammar) are refused by the + ``http-sf`` and ``http-message-signatures`` serializers. """ if alg not in ALLOWED_ALGS: raise ValueError(f"alg must be one of {sorted(ALLOWED_ALGS)}, got {alg!r}") @@ -160,13 +112,20 @@ def _prepare_signature( raise ValueError( f"expires_in_seconds must be in (0, {MAX_WINDOW_SECONDS}], got {expires_in_seconds}" ) - if not key_id: - raise ValueError("key_id must be a non-empty string") - _validate_sf_key(label, field="label") + for name, value in (("key_id", key_id), ("label", label), ("tag", tag)): + if not value: + raise ValueError(f"{name} must be a non-empty string") + if nonce is not None and not nonce: + raise ValueError("nonce must be a non-empty string") + try: + # A label is an RFC 8941 key in both fields; refuse a bad one before + # anything is signed. + _rfc9421.serialize({label: (True, {})}) + except ValueError as exc: + raise ValueError(f"label {label!r} is not a valid RFC 8941 key: {exc}") from exc if created is None: created = int(time.time()) - expires = created + expires_in_seconds if nonce is None: nonce = b64url_encode(secrets.token_bytes(NONCE_BYTES)) @@ -177,54 +136,36 @@ def _prepare_signature( components.append("content-type") if cover_content_digest: content_digest_value = compute_content_digest_sha256(body) + outgoing_headers = { + k: v for k, v in outgoing_headers.items() if k.lower() != "content-digest" + } outgoing_headers["Content-Digest"] = content_digest_value components.append("content-digest") - - comp_serialized = "(" + " ".join(f'"{c}"' for c in components) + ")" - nonce_escaped = _escape_sf_string(nonce, field="nonce") - key_id_escaped = _escape_sf_string(key_id, field="key_id") - tag_escaped = _escape_sf_string(tag, field="tag") - params_serialized = ( - f';created={created};expires={expires};nonce="{nonce_escaped}"' - f';keyid="{key_id_escaped}";alg="{alg}";tag="{tag_escaped}"' - ) - raw_value = comp_serialized + params_serialized - - parsed = SignatureInputLabel( - label=label, - components=tuple(components), - params={ - "created": created, - "expires": expires, - "nonce": nonce, - "keyid": key_id, - "alg": alg, - "tag": tag, - }, - raw_value=raw_value, - ) - base = build_signature_base( - method=method, url=url, headers=outgoing_headers, parsed=parsed - ).encode("utf-8") - return _PreparedSignature( - base=base, - raw_value=raw_value, - label=label, + return _PreparedRequest( + message=_rfc9421.Request(method=method, url=url, headers=outgoing_headers), + covered_component_ids=tuple( + _rfc9421.component_identifier((name, {})) for name in components + ), + created=created, + expires=created + expires_in_seconds, + nonce=nonce, content_digest_value=content_digest_value, ) -def _assemble_headers( - prepared: _PreparedSignature, - sig_bytes: bytes, +def _signed_headers( + prepared: _PreparedRequest, *, + signature_input: str, + signature: bytes, + label: str, signing_profile_version: Literal["3.0", "3.1", "3.2"], ) -> SignedHeaders: return SignedHeaders( - signature_input=f"{prepared.label}={prepared.raw_value}", + signature_input=signature_input, signature=format_signature_header( - sig_bytes, - label=prepared.label, + signature, + label=label, use_legacy_base64url=signing_profile_version != "3.2", ), content_digest=prepared.content_digest_value, @@ -276,6 +217,11 @@ def sign_request( if agent_origin is not None: from adcp.signing.wba import public_directory_key, sign_wba_request + if not isinstance(private_key, ed25519.Ed25519PrivateKey): + raise ValueError( + "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " + "RFC 9421 profile algorithm only" + ) if key_id != public_directory_key(private_key)["kid"]: raise ValueError("Web Bot Auth key_id must be the public key's RFC 7638 thumbprint") result = sign_wba_request( @@ -301,7 +247,7 @@ def sign_request( body=body, cover_content_digest=cover_content_digest, ) - prepared = _prepare_signature( + prepared = _prepare_request( method=method, url=url, headers=headers, @@ -315,10 +261,30 @@ def sign_request( tag=tag, label=label, ) - sig_bytes = sign_signature_base(alg=alg, private_key=private_key, signature_base=prepared.base) - return _assemble_headers( + try: + # Fails before signing when the key does not fit ``alg``. + _rfc9421.ALGORITHMS[alg](private_key=private_key) + except HTTPMessageSignaturesException as exc: + expected = "Ed25519 key" if alg == ALG_ED25519 else "EC P-256 key" + raise ValueError(f"alg/private_key mismatch: expected {expected}") from exc + signature_input, signature = _rfc9421.sign( + prepared.message, + alg=alg, + private_key=private_key, + key_id=key_id, + label=label, + created=prepared.created, + expires=prepared.expires, + nonce=prepared.nonce, + tag=tag, + covered_component_ids=prepared.covered_component_ids, + resolver=_rfc9421.AdcpComponentResolver, + ) + return _signed_headers( prepared, - sig_bytes, + signature_input=signature_input, + signature=extract_signature_bytes(signature, label, allow_legacy_base64url=False), + label=label, signing_profile_version=signing_profile_version, ) @@ -359,13 +325,15 @@ async def async_sign_request( body=body, cover_content_digest=cover_content_digest, ) - prepared = _prepare_signature( + key_id = provider.key_id() + alg = provider.algorithm() + prepared = _prepare_request( method=method, url=url, headers=headers, body=body, - key_id=provider.key_id(), - alg=provider.algorithm(), + key_id=key_id, + alg=alg, cover_content_digest=resolved_cover_content_digest, created=created, expires_in_seconds=expires_in_seconds, @@ -373,10 +341,25 @@ async def async_sign_request( tag=tag, label=label, ) - sig_bytes = await provider.sign(prepared.base) - return _assemble_headers( + # The same base and parameters HTTPMessageSigner.sign produces, so the + # headers match sign_request byte for byte. + base, member = _rfc9421.signature_base( + prepared.message, + alg=alg, + key_id=key_id, + created=prepared.created, + expires=prepared.expires, + nonce=prepared.nonce, + tag=tag, + covered_component_ids=prepared.covered_component_ids, + resolver=_rfc9421.AdcpComponentResolver, + ) + signature = await provider.sign(base) + return _signed_headers( prepared, - sig_bytes, + signature_input=f"{label}={member}", + signature=signature, + label=label, signing_profile_version=signing_profile_version, ) diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 84ac75f53..272ad254d 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -4,6 +4,11 @@ defined in `security.mdx`. Each step either passes or raises `SignatureVerificationError` with the exact code from the transport error taxonomy — conformance requires byte-for-byte match on the code string. + +RFC 9421 comes from libraries: ``http-sf`` parses the fields at step 1, and +``http-message-signatures`` builds the signature base and verifies it at +step 10. The checklist order, the profile checks between those steps, and +the mapping of every failure onto an AdCP code stay here. """ from __future__ import annotations @@ -15,12 +20,12 @@ from datetime import datetime, timezone from typing import Any, Literal +from adcp.signing import _rfc9421 from adcp.signing._header_precheck import strict_header_precheck from adcp.signing._strict_json import StrictJsonError, body_is_empty, parse_strict_json from adcp.signing.canonical import ( TargetUriMalformedError, _lookup, - build_signature_base, parse_signature_input_header, ) from adcp.signing.constants import ( @@ -36,8 +41,8 @@ ALLOWED_ALGS, alg_for_jwk, extract_signature_bytes, + format_signature_header, public_key_from_jwk, - verify_signature, ) from adcp.signing.digest import content_digest_matches from adcp.signing.errors import ( @@ -82,6 +87,7 @@ REQUIRED_COMPONENTS = ("@method", "@target-uri", "@authority") REQUIRED_PARAMS = ("created", "expires", "nonce", "keyid", "alg", "tag") _INT_PARAMS = frozenset({"created", "expires"}) +_SIGNATURE_FIELDS = frozenset({"signature", "signature-input"}) _STR_PARAMS = frozenset({"nonce", "keyid", "alg", "tag"}) # Defensive upper bound against log/dict-key poisoning. RFC 7517 has no hard kid @@ -432,42 +438,57 @@ def verify_request_signature( ) try: - base = build_signature_base(method=method, url=url, headers=headers, parsed=parsed).encode( - "utf-8" - ) - except TargetUriMalformedError as exc: - # MUST precede the (ValueError, KeyError) clause below: TargetUriMalformedError - # IS a ValueError, so reordering these two silently downgrades every - # malformed-authority rejection to request_signature_header_malformed - # with the suite still green. test_target_uri_malformed_boundary.py - # guards the ordering. - raise SignatureVerificationError( - exc.code, - step=6, - message=f"signature base construction failed: {exc}", - ) from exc + public_key = public_key_from_jwk(jwk) except (ValueError, KeyError) as exc: raise SignatureVerificationError( REQUEST_SIGNATURE_HEADER_MALFORMED, - step=6, - message=f"signature base construction failed: {exc}", + step=8, + message=f"JWK decode failed: {exc}", ) from exc + # Step 10: http-message-signatures builds the base and verifies. The + # selected label is handed over alone, re-serialized, with its signature + # in RFC 8941 form, so the other labels the profile ignores (and the + # 3.0/3.1 Base64URL spelling) never reach the library. + message = _rfc9421.Request( + method=method, + url=url, + headers={ + **{k: v for k, v in headers.items() if k.lower() not in _SIGNATURE_FIELDS}, + "Signature-Input": f"{options.label}={parsed.raw_value}", + "Signature": format_signature_header(sig_bytes, label=options.label), + }, + ) try: - public_key = public_key_from_jwk(jwk) - except (ValueError, KeyError) as exc: + _rfc9421.verify( + message, + label=options.label, + tag=str(parsed.params["tag"]), + alg=alg, + public_key=public_key, + resolver=_rfc9421.AdcpComponentResolver, + parameters=tuple(parsed.params), + ) + except _rfc9421.SignatureBaseError as exc: + if isinstance(exc.cause, TargetUriMalformedError): + # A malformed authority keeps its own code (step 6 of the + # canonicalization rules), not request_signature_header_malformed. + # test_target_uri_malformed_boundary.py guards the distinction. + raise SignatureVerificationError( + exc.cause.code, + step=6, + message=f"signature base construction failed: {exc.cause}", + ) from exc raise SignatureVerificationError( REQUEST_SIGNATURE_HEADER_MALFORMED, - step=8, - message=f"JWK decode failed: {exc}", + step=6, + message=f"signature base construction failed: {exc}", ) from exc - if not verify_signature( - alg=alg, public_key=public_key, signature_base=base, signature=sig_bytes - ): + except _rfc9421.SignatureMismatchError as exc: raise SignatureVerificationError( REQUEST_SIGNATURE_INVALID, step=10, message="signature did not verify over the computed base", - ) + ) from exc body_authenticated = False if "content-digest" in parsed.components: diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 3d8535822..205adf1f0 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -9,7 +9,16 @@ 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. +The profile is adcontextprotocol/adcp#7894 (``wba-profile.mdx``): one +``web-bot-auth`` signature and one ``Signature-Agent`` member per request, +``ed25519`` or ``rsa-pss-sha512``, a 64-byte nonce on every request, and key +directories signed per WBA Appendix B.1. RFC 9421 itself comes from +``http-message-signatures`` (signature bases, signing, verification) and +``http-sf`` (Structured Fields); this module keeps the profile rules. + +Governance tokens under this profile are checked by +:func:`verify_wba_governance_issuer` (key in the ``iss`` origin's directory, +``iss`` is the buyer's governance agent). """ from __future__ import annotations @@ -28,24 +37,15 @@ from urllib.parse import urlsplit import httpx -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 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from adcp.signing import _rfc9421 from adcp.signing._bounded_http import async_read_limited_bytes from adcp.signing._header_precheck import strict_header_precheck from adcp.signing._strict_json import StrictJsonError, parse_strict_json -from adcp.signing._structured_fields import dictionary_has_parameter, reject_dictionary_duplicates from adcp.signing.canonical import _lookup, canonical_origin -from adcp.signing.crypto import ( - b64url_decode, - b64url_encode, - public_key_from_jwk, - sign_signature_base, - verify_signature, -) +from adcp.signing.crypto import b64url_decode, b64url_encode, public_key_from_jwk from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches from adcp.signing.jwks import SSRFValidationError, validate_uri_static from adcp.signing.publisher_pins import jwk_thumbprint @@ -57,11 +57,14 @@ DIRECTORY_TAG = "http-message-signatures-directory" DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json" +ALG_ED25519 = "ed25519" ALG_RSA_PSS_SHA512 = "rsa-pss-sha512" +#: The algorithms the profile allows (``wba-profile.mdx``, Profile rules). +#: ES256 is an AdCP RFC 9421 profile algorithm only. +WBA_ALGORITHMS: frozenset[str] = frozenset({ALG_ED25519, ALG_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`). +#: Every signed request carries a nonce of exactly 64 random bytes +#: (``wba-profile.mdx``, Profile rules; the architecture draft §4.2.2). 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. @@ -81,6 +84,8 @@ _REQUEST_SKEW_SECONDS = 60 _RSA_MIN_BITS, _RSA_MAX_BITS = 2048, 8192 _PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k", "oth"}) +_REQUEST_COMPONENTS = ("@method", "@target-uri", "@authority") +_BODY_COMPONENTS = ("content-type", "content-digest") #: 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. @@ -107,9 +112,10 @@ def _new_nonce() -> str: 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. + The profile's spelling is unpadded base64url, which this SDK's signer + sends. Padded standard base64 of 64 bytes is accepted too: Cloudflare's + ``web-bot-auth`` signer and the protocol draft's Appendix E vectors emit + it, and the bytes are what make the nonce. """ if type(nonce) is not str or not nonce.isascii(): return False @@ -130,18 +136,13 @@ def is_64_byte_nonce(nonce: object) -> bool: def valid_wba_nonce(nonce: object) -> bool: - """Whether a received nonce is acceptable: 22 to 256 printable characters. + """Whether a received nonce is acceptable: exactly 64 bytes (see :func:`is_64_byte_nonce`). - 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. + The profile requires a 64-byte nonce on every signed request, reads + included (``wba-profile.mdx``, Profile rules and Replay protection). A + shorter nonce is a profile failure: 403 with ``Accept-Signature``. """ - return ( - type(nonce) is str - and 22 <= len(nonce) <= 256 - and all(0x21 <= ord(c) <= 0x7E for c in nonce) - ) + return is_64_byte_nonce(nonce) class WbaVerificationError(ValueError): @@ -149,7 +150,8 @@ class WbaVerificationError(ValueError): ``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 + revoked) or 429 (a reused nonce, with ``Accept-Signature`` carrying a + fresh nonce; or local capacity, without it). Answer it with :data:`GENERIC_AUTHENTICATION_ERROR`; ``reason`` is for logs only and must never be sent to the caller. """ @@ -164,41 +166,43 @@ def __init__( status: int = 403, label: str = "sig1", challenge: bool = True, + fresh_nonce: bool = False, ) -> None: super().__init__(reason) self.reason = reason self.status = status self.label = label - self.headers: dict[str, str] = {} - if status == 403 and challenge: - self.headers["Accept-Signature"] = accept_signature_value(label) + self.challenge = challenge and status in {403, 429} + self._nonce = _new_nonce() if fresh_nonce else None + @property + def headers(self) -> dict[str, str]: + """``Accept-Signature`` for a 403 or a replay 429, else no headers.""" + if not self.challenge: + return {} + return {"Accept-Signature": accept_signature_value(self.label, nonce=self._nonce)} -def accept_signature_value(label: str = "sig1") -> str: + +def accept_signature_value(label: str = "sig1", *, nonce: str | None = None) -> str: """``Accept-Signature`` naming what the profile requires (RFC 9421 §5.1). - Parameters indicate requirements, not values to sign; the nonce is a - fresh 64-byte value the signer may use on its next attempt. + The components and parameters of ``wba-profile.mdx``, Error responses: + parameters name requirements, not values to sign. A replay answer passes + ``nonce``, the fresh value the signer uses 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})) + components: list[tuple[str, dict[str, Any]]] = [ + (name, {}) for name in (*_REQUEST_COMPONENTS, *_BODY_COMPONENTS) + ] + components.append(("signature-agent", {"key": label})) + params: dict[str, Any] = { + "created": True, + "expires": True, + "nonce": nonce if nonce is not None else True, + "keyid": True, + "alg": True, + "tag": WBA_TAG, + } + return _rfc9421.serialize({label: (components, params)}) class WbaDiscoveryError(WbaVerificationError): @@ -242,19 +246,15 @@ class WbaUnavailableError(RuntimeError): """ -def _dictionary(value: str | None, *, missing_status: int = 400) -> Dictionary: +def _dictionary(value: str | None, *, missing_status: int = 400) -> dict[str, Any]: + """Parse a signature field with http-sf; a field that does not parse is 400.""" 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() - result.parse(value.encode("ascii")) - if not result: - raise ValueError("empty dictionary") - return result - except (ValueError, UnicodeError) as exc: + return _rfc9421.parse_dictionary(value) + except ValueError as exc: raise WbaVerificationError(f"malformed signature field: {exc}", status=400) from exc @@ -267,7 +267,7 @@ def uses_web_bot_auth(headers: Mapping[str, str]) -> bool: return False # Observe every explicit tag, including one an ambiguous duplicate would # overwrite. Text inside nonce/component strings cannot select a profile. - return dictionary_has_parameter(raw, "tag", WBA_TAG) + return _rfc9421.dictionary_has_parameter(raw, "tag", WBA_TAG) def _https_origin(url: str) -> str: @@ -289,7 +289,7 @@ def _https_origin(url: str) -> str: def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: - """Return an RFC 7638 thumbprint for supported public key material.""" + """Return the RFC 7638 thumbprint (``joserfc``) for supported public key material.""" digest = jwk_thumbprint(jwk) if digest is None: raise ValueError("incomplete public JWK") @@ -300,14 +300,14 @@ 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 +WbaPrivateKey = ed25519.Ed25519PrivateKey | 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. + Ed25519 and RSA (2048 to 8192 bits, used with ``rsa-pss-sha512``) keys + are supported: the profile's two algorithms. """ public = private_key.public_key() jwk: dict[str, Any] @@ -319,21 +319,13 @@ def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) ), } - elif isinstance(public, ec.EllipticCurvePublicKey) and isinstance(public.curve, ec.SECP256R1): - numbers = public.public_numbers() - jwk = { - "kty": "EC", - "crv": "P-256", - "x": b64url_encode(numbers.x.to_bytes(32, "big")), - "y": b64url_encode(numbers.y.to_bytes(32, "big")), - } 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, P-256 and RSA keys") + raise ValueError("Web Bot Auth supports Ed25519 and RSA keys") jwk.update({"kid": thumbprint_key_id(jwk), "use": "sig"}) return jwk @@ -362,17 +354,18 @@ def _rsa_public_key(jwk: Mapping[str, Any]) -> rsa.RSAPublicKey: def _algorithm(jwk: Mapping[str, Any]) -> str: - """Derive the signature algorithm and check the key may verify.""" + """Derive the signature algorithm and check the key may verify. + + Only the profile's algorithms qualify: an Ed25519 key signs ``ed25519`` + and an RSA key ``rsa-pss-sha512``. Any other key (P-256 included) is not + a Web Bot Auth key. + """ shape = (str(jwk.get("kty")), str(jwk.get("crv"))) - alg = { - ("OKP", "Ed25519"): "ed25519", - ("EC", "P-256"): "ecdsa-p256-sha256", - ("RSA", "None"): ALG_RSA_PSS_SHA512, - }.get(shape) + alg = {("OKP", "Ed25519"): ALG_ED25519, ("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") # One key, one thumbprint: key members must be canonical base64url. - for member in ("x", "y", "n", "e"): + for member in ("x", "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: @@ -380,7 +373,7 @@ def _algorithm(jwk: Mapping[str, Any]) -> str: _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] + jws_alg = {ALG_ED25519: "EdDSA", 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 ( @@ -390,38 +383,10 @@ 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: +def _public_key(alg: str, jwk: Mapping[str, Any]) -> Any: 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, - ) + return _rsa_public_key(jwk) + return public_key_from_jwk(dict(jwk)) def _received_authority(url: str) -> str: @@ -442,72 +407,59 @@ def _received_authority(url: str) -> str: 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 +class _WbaComponentResolver(_rfc9421.ComponentResolver): + """Web Bot Auth components: the target URI as received, not canonicalized. + + ``@target-uri`` is the URI exactly as sent or received (RFC 9421 + §2.2.2), percent-encoding included; ``@authority`` is its normalized + authority. The AdCP origin canonicalization identifies the signer only. + """ + def get_target_uri(self) -> str: + url = self.url + if any(ord(c) <= 0x20 or ord(c) == 0x7F for c in url) or not url.isascii(): + raise _rfc9421.ComponentError("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 _rfc9421.ComponentError("request URL is not an absolute target URI") + return url -def _base( - method: str, - url: str, - headers: Mapping[str, str], - member: InnerList, - *, - directory_response: bool = False, + def get_authority(self) -> str: + try: + return _received_authority(self.url) + except WbaVerificationError as exc: + raise _rfc9421.ComponentError(exc.reason) from exc + + +def _signature_base( + label: str, method: str, url: str, headers: Mapping[str, str], *, response: bool = False ) -> bytes: - """Build the RFC 9421 §2.5 signature base for ``member``. + """The signature base for ``label`` in ``headers["Signature-Input"]``. - ``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. + For diagnostics and conformance tests: verification builds the same base + inside ``http-message-signatures``. """ - lines: list[str] = [] - for component in member: - name = component.value - if type(name) is not str: - raise WbaVerificationError("component must be a string", status=400) - params = component.params - if name.startswith("@"): - expected = {"req": True} if directory_response and name == "@authority" else {} - if dict(params) != expected: - raise WbaVerificationError("unsupported derived component parameters") - if name == "@method": - value = method.upper() - elif name == "@target-uri": - value = _target_uri(url) - elif name == "@authority": - 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") - dictionary = _dictionary(raw) - if params["key"] not in dictionary: - raise WbaVerificationError("missing covered dictionary member") - value = str(dictionary[params["key"]]) - else: - value = raw.strip(" \t") - lines.append(f"{component}: {value}") - lines.append(f'"@signature-params": {member}') - return "\n".join(lines).encode("utf-8") + member = _dictionary(_lookup(headers, "signature-input"))[label] + items, params = member + request = _rfc9421.Request(method=method, url=url, headers=dict(headers)) + message: _rfc9421.Request | _rfc9421.Response = ( + _rfc9421.Response( + status_code=200, request=_rfc9421.Request("GET", url), headers=dict(headers) + ) + if response + else request + ) + return _rfc9421.build_base( + message, + covered_component_ids=[_rfc9421.component_identifier(item) for item in items], + params=params, + resolver=_WbaComponentResolver, + ) -def _window(member: InnerList, *, now: float, directory: bool = False, label: str = "sig1") -> None: - params = member.params +def _window( + params: Mapping[str, Any], *, now: float, directory: bool = False, label: str = "sig1" +) -> None: if type(params.get("tag")) is not str: raise WbaVerificationError("signature tag must be an sf-string", label=label) for name in ("created", "expires"): @@ -527,19 +479,43 @@ def _window(member: InnerList, *, now: float, directory: bool = False, label: st raise WbaVerificationError("missing thumbprint keyid", label=label) -def _verify_crypto(member: InnerList, signature: Any, jwk: Mapping[str, Any], base: bytes) -> str: +def _verify_crypto( + message: _rfc9421.Request | _rfc9421.Response, + *, + label: str, + params: Mapping[str, Any], + signature: Any, + jwk: Mapping[str, Any], +) -> str: + """Verify ``label`` with ``http-message-signatures``; return the algorithm. + + The algorithm comes from the key (:func:`_algorithm`), and the signature's + ``alg`` must name the same one. + """ alg = _algorithm(jwk) - declared = member.params.get("alg", alg) + declared = 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): + if not isinstance(signature, tuple) or not isinstance(signature[0], bytes): raise WbaVerificationError("signature must be sf-binary", status=400) try: - valid = _verify(alg, jwk, base, signature.value) + public_key = _public_key(alg, jwk) except (ValueError, KeyError, TypeError) as exc: raise WbaVerificationError("invalid directory key") from exc - if not valid: - raise WbaVerificationError("signature did not verify") + try: + _rfc9421.verify( + message, + label=label, + tag=str(params["tag"]), + alg=alg, + public_key=public_key, + resolver=_WbaComponentResolver, + parameters=tuple(params), + ) + except _rfc9421.SignatureBaseError as exc: + raise WbaVerificationError(exc.reason, status=exc.status) from exc + except _rfc9421.SignatureMismatchError as exc: + raise WbaVerificationError("signature did not verify") from exc return alg @@ -560,9 +536,11 @@ def sign_wba_request( ``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. + included. The nonce defaults to 64 random bytes, and an explicit one must + be 64 bytes too. A request that already carries signature fields is + refused rather than stripped: another party's signature is never + silently dropped. ``http-message-signatures`` builds the base, signs, + and serializes ``Signature-Input`` and ``Signature``. """ origin = _https_origin(agent_origin) if not 0 < expires_in_seconds <= 300: @@ -575,53 +553,50 @@ def sign_wba_request( "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") + if not label: + raise ValueError("label must be a non-empty RFC 8941 key") jwk = public_directory_key(private_key) alg = _algorithm(jwk) created = int(time.time()) if created is None else created 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" - ) - outgoing = {"Signature-Agent": str(Dictionary({label: Item(origin)}))} - components = [Item(c) for c in ("@method", "@target-uri", "@authority")] + raise ValueError("nonce must encode exactly 64 bytes") + try: + agent = _rfc9421.serialize({label: (origin, {})}) + except ValueError as exc: + raise ValueError(f"label {label!r} is not a valid RFC 8941 key: {exc}") from exc + outgoing = {"Signature-Agent": agent} + components = list(_REQUEST_COMPONENTS) if body: if _lookup(headers, "content-type") is None: raise ValueError("body-bearing WBA requests require Content-Type") outgoing["Content-Digest"] = compute_content_digest_sha256(body) - components.extend(Item(c) for c in ("content-type", "content-digest")) - agent = Item("signature-agent") - agent.params["key"] = label - components.append(agent) - member = InnerList(list(components)) - member.params.update( - { - "created": created, - "expires": created + expires_in_seconds, - "nonce": nonce, - "keyid": jwk["kid"], - "alg": alg, - "tag": WBA_TAG, - } + components.extend(_BODY_COMPONENTS) + covered = [_rfc9421.component_identifier((name, {})) for name in components] + covered.append(_rfc9421.component_identifier(("signature-agent", {"key": label}))) + message = _rfc9421.Request( + method=method, + url=url, + headers={ + **{k.lower(): v for k, v in headers.items()}, + **{k.lower(): v for k, v in outgoing.items()}, + }, ) - signature = _sign( - alg, - private_key, - _base( - method, - url, - { - **{k.lower(): v for k, v in headers.items()}, - **{k.lower(): v for k, v in outgoing.items()}, - }, - member, - ), + signature_input, signature = _rfc9421.sign( + message, + alg=alg, + private_key=private_key, + key_id=jwk["kid"], + label=label, + created=created, + expires=created + expires_in_seconds, + nonce=nonce, + tag=WBA_TAG, + covered_component_ids=covered, + resolver=_WbaComponentResolver, ) - outgoing["Signature-Input"] = str(Dictionary({label: member})) - outgoing["Signature"] = str(Dictionary({label: Item(signature)})) + outgoing["Signature-Input"] = signature_input + outgoing["Signature"] = signature return outgoing @@ -637,8 +612,8 @@ class DirectoryResponse: @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 + #: Earliest signature ``expires`` across the directory's signatures. + signatures_expire: float def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: @@ -678,7 +653,7 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: def _verify_directory( - origin: str, response: DirectoryResponse, *, now: float, require_signed: bool + origin: str, response: DirectoryResponse, *, now: float ) -> _VerifiedDirectory: origin = _https_origin(origin) media_type = (_lookup(response.headers, "content-type") or "").split(";", 1)[0] @@ -690,11 +665,9 @@ def _verify_directory( 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) + if raw_inputs is None or raw_signatures is None: + # The profile requires signed directory responses (Appendix B.1). + raise WbaVerificationError("key directory is not signed") 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): @@ -704,47 +677,48 @@ def _verify_directory( signatures = _dictionary(raw_signatures) if set(signatures) != set(inputs): raise WbaVerificationError("directory signature labels differ") + message = _rfc9421.Response( + status_code=response.status, + request=_rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH), + headers=dict(response.headers), + ) 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: + if not _rfc9421.is_inner_list(member) or member[1].get("tag") != DIRECTORY_TAG: raise WbaVerificationError("invalid directory signature profile") - _window(member, now=now, directory=True, label=label) - covered = {str(c) for c in member} + items, params = member + _window(params, now=now, directory=True, label=label) + covered = {_rfc9421.component_identifier(item) for item in items} # 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"] + keyid = params["keyid"] if keyid not in keys or keyid in signed: raise WbaVerificationError("directory signature key is absent or repeated") _verify_crypto( - member, - signatures.get(label), - keys[keyid], - _base( - "GET", origin + DIRECTORY_PATH, response.headers, member, directory_response=True - ), + message, label=label, params=params, signature=signatures.get(label), jwk=keys[keyid] ) 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. + earliest = min(earliest, float(params["expires"])) + # 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 + origin: str, response: DirectoryResponse, *, now: float ) -> 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. + The response must be signed per WBA Appendix B.1, as the profile + requires: every signature must verify with a key the directory holds, + cover ``"@authority";req`` and ``"content-digest"``, and only the keys + those signatures prove are returned. An unsigned directory is refused. + Every key is indexed by its RFC 7638 thumbprint (a ``kid`` is optional). """ - return _verify_directory(origin, response, now=now, require_signed=require_signed).keys + return _verify_directory(origin, response, now=now).keys def sign_key_directory( @@ -756,10 +730,11 @@ def sign_key_directory( ) -> DirectoryResponse: """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. + One signature per key (WBA Appendix B.1), each from + ``HTTPMessageSigner.sign``. 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) <= _DIRECTORY_MAX_KEYS or expires_in_seconds <= 0: @@ -778,31 +753,26 @@ def sign_key_directory( created, expires = now - DIRECTORY_SKEW_ALLOWANCE_SECONDS, now + expires_in_seconds else: expires = created + expires_in_seconds - inputs, signatures = Dictionary(), Dictionary() + request = _rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH) + inputs: list[str] = [] + signatures: list[str] = [] 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": expires, - "keyid": jwk["kid"], - "alg": alg, - "tag": DIRECTORY_TAG, - } + signature_input, signature = _rfc9421.sign( + _rfc9421.Response(status_code=200, request=request, headers=dict(headers)), + alg=_algorithm(jwk), + private_key=key, + key_id=jwk["kid"], + label=f"key{number}", + created=created, + expires=expires, + nonce=None, + tag=DIRECTORY_TAG, + covered_component_ids=['"@authority";req', '"content-digest"'], + resolver=_WbaComponentResolver, ) - label = f"key{number}" - inputs[label] = member - signatures[label] = Item( - _sign( - alg, - key, - _base("GET", origin + DIRECTORY_PATH, headers, member, directory_response=True), - ) - ) - headers.update({"Signature-Input": str(inputs), "Signature": str(signatures)}) + inputs.append(signature_input) + signatures.append(signature) + headers.update({"Signature-Input": ", ".join(inputs), "Signature": ", ".join(signatures)}) return DirectoryResponse(body=body, headers=headers) @@ -901,7 +871,6 @@ 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, @@ -923,7 +892,6 @@ def __init__( clock, ) self._max_origins_per_operator = max_origins_per_operator - 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() @@ -994,7 +962,7 @@ async def __call__( entry.expires, entry.refresh_at + _DIRECTORY_CACHE_SECONDS ): return self._lookup_key(origin, entry, keyid, stale=True) - raise WbaVerificationError(refused, status=429) + raise WbaVerificationError(refused, status=429, challenge=False) 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. @@ -1028,9 +996,7 @@ async def _refresh(self, origin: str) -> _DirectoryEntry: 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 - ) + verified = _verify_directory(origin, response, now=self._clock()) except asyncio.CancelledError: raise except Exception as exc: @@ -1046,9 +1012,7 @@ async def _refresh(self, origin: str) -> _DirectoryEntry: 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) + expires = min(now + self._max_stale, verified.signatures_expire) entry = _DirectoryEntry( response, verified.keys, min(now + _DIRECTORY_CACHE_SECONDS, expires), expires ) @@ -1074,31 +1038,34 @@ async def verify_wba_request( 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 every WBA signature and relay coverage; return established origins. + """Verify the request's one WBA signature; return the established origin. + + The verification procedure of ``wba-profile.mdx``: the request carries + exactly one ``web-bot-auth`` signature and exactly one ``Signature-Agent`` + member, keyed to that signature's label. The result is a one-element + tuple holding that signer. ``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. + and grants no authority. 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. + status and headers are the whole answer: 400 when a field does not parse, + 403 with ``Accept-Signature`` for any other signature failure, and 429 + with ``Accept-Signature`` carrying a fresh nonce for a reused nonce. A + replay-store failure raises :class:`WbaUnavailableError`, an + application-layer outage. ``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. + a revoked key raises :class:`WbaRevokedKeyError`, and the signer records + its revocation state. """ if replay_store is None: raise ValueError("Web Bot Auth requires a replay store") @@ -1114,138 +1081,122 @@ async def verify_wba_request( 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() + # Selection by tag alone leaves the Signature-Agent member absent, which + # fails the signature with 403 below. + agents = _dictionary(raw_agent) if raw_agent is not None else {} wba = [ (label, member) for label, member in inputs.items() - if isinstance(member, InnerList) and member.params.get("tag") == WBA_TAG + if _rfc9421.is_inner_list(member) and member[1].get("tag") == WBA_TAG ] if not wba: raise WbaVerificationError("Signature-Agent requires the Web Bot Auth profile") if set(signatures) != set(inputs): raise WbaVerificationError("signature labels do not match", status=400) + label, (items, params) = wba[0] + if len(wba) > 1 or len(agents) > 1: + # One signature, one Signature-Agent member (Profile rules, "One + # signature"). An intermediary that signs is the signer; acting for + # another party is an authorization question, not a second signature. + raise WbaVerificationError( + "the profile takes one web-bot-auth signature and one Signature-Agent member", + label=label, + ) 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, label=label) - nonce = member.params.get("nonce") - 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) - or type(agent.value) is not str - or agent.params.get("type", "directory") != "directory" - ): - raise WbaVerificationError("missing supported Signature-Agent member", label=label) - try: - origin = _https_origin(agent.value) - except ValueError as exc: - raise WbaVerificationError("invalid signer origin", label=label) from exc - covered = {str(c) for c in member} - required = { - '"@method"', - '"@target-uri"', - '"@authority"', - f'"signature-agent";key="{label}"', - } - if body: - required.update({'"content-type"', '"content-digest"'}) - for previous in earlier: - required.update( - f'"{field}";key="{previous}"' - for field in ("signature-agent", "signature-input", "signature") - ) - # RFC 9421 §2.2.8 also requires all components of the signature - # being covered, including application-specific header fields. - required.update(str(component) for component in inputs[previous]) - if not required <= covered or len(covered) != len(member): - raise WbaVerificationError("missing or repeated covered component", label=label) - keyid = member.params["keyid"] - try: - key = await signer_keys(keyid, signer_origin=origin) - except WbaVerificationError 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: - # 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) - 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) - verified.append( - ( - VerifiedSigner( - key_id=keyid, - alg=alg, - label=label, - verified_at=now, - agent_url=origin, - body_authenticated=bool(body), - profile=WBA_TAG, - ), - nonce, - # 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), + "request authority is not served by this verifier", label=label ) + _window(params, now=now, label=label) + nonce = params.get("nonce") + if not valid_wba_nonce(nonce): + raise WbaVerificationError("missing or invalid nonce", label=label) + nonce = cast(str, nonce) + agent = agents.get(label) + if ( + agent is None + or _rfc9421.is_inner_list(agent) + or type(agent[0]) is not str + or agent[1].get("type", "directory") != "directory" + ): + raise WbaVerificationError("missing supported Signature-Agent member", label=label) + try: + origin = _https_origin(agent[0]) + except ValueError as exc: + raise WbaVerificationError("invalid signer origin", label=label) from exc + covered = [_rfc9421.component_identifier(item) for item in items] + required = {_rfc9421.component_identifier((name, {})) for name in _REQUEST_COMPONENTS} + required.add(_rfc9421.component_identifier(("signature-agent", {"key": label}))) + if body: + required.update(_rfc9421.component_identifier((name, {})) for name in _BODY_COMPONENTS) + if not required <= set(covered) or len(set(covered)) != len(covered): + raise WbaVerificationError("missing or repeated covered component", label=label) + keyid = params["keyid"] + try: + key = await signer_keys(keyid, signer_origin=origin) + except WbaVerificationError as exc: + exc.label = label + raise + except Exception as exc: + # 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) + assert key is not None + message = _rfc9421.Request(method=method, url=url, headers=dict(headers)) + try: + alg = _verify_crypto( + message, label=label, params=params, signature=signatures.get(label), jwk=key.jwk + ) + 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) + # One extra second keeps the nonce through the last second of the + # accepted window, including a replay at expires + 60. + ttl = max(params["expires"] - now + _REQUEST_SKEW_SECONDS + 1, 1) + try: + claim = await asyncio.to_thread(_claim_replay_nonce, replay_store, origin, nonce, ttl) + except Exception as exc: + raise WbaUnavailableError("replay store unavailable") from exc + if claim == "replayed": + # A reused nonce is 429 with Accept-Signature carrying a fresh nonce + # (Error responses; WBA section 5.3). + raise WbaVerificationError("replayed nonce", status=429, label=label, fresh_nonce=True) + if claim != "claimed": + raise WbaVerificationError( + "replay store capacity exceeded", status=429, label=label, challenge=False ) - earlier.append(label) - # 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( - "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 - signers = tuple(replace(signer, parsed_body=parsed) for signer, _, _ in verified) + signers: tuple[VerifiedSigner, ...] = ( + VerifiedSigner( + key_id=keyid, + alg=alg, + label=label, + verified_at=now, + agent_url=origin, + body_authenticated=bool(body), + profile=WBA_TAG, + parsed_body=parsed, + ), + ) if revocation_checker is not None: signers = await check_wba_revocation(revocation_checker, signers) return signers @@ -1277,10 +1228,10 @@ async def check_wba_revocation( ) -> 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. + Runs in the authorization layer, after verification. A revoked key + 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: @@ -1291,6 +1242,153 @@ async def check_wba_revocation( return tuple(checked) +WBA_GOVERNANCE_TYP = "adcp-gov+jws" +#: JWS ``alg`` values a governance token may carry under the profile, by the +#: Web Bot Auth algorithm of the issuer's key. ``Ed25519`` is RFC 9864's +#: fully specified name for ``EdDSA`` over Ed25519. +_GOVERNANCE_JWS_ALGS: dict[str, frozenset[str]] = { + ALG_ED25519: frozenset({"EdDSA", "Ed25519"}), + ALG_RSA_PSS_SHA512: frozenset({"PS512"}), +} + + +class WbaGovernanceError(ValueError): + """A governance token failed a Web Bot Auth governance check. + + ``code`` is the governance error code from ``security.mdx`` (Verification + error taxonomy); ``reason`` is for logs only. + """ + + def __init__(self, code: str, reason: str) -> None: + super().__init__(f"{code}: {reason}") + self.code = code + self.reason = reason + + +def _governance_registry(alg: str) -> Any: + from joserfc.jws import JWSRegistry + + from adcp.signing.jws import _AdcpJwsRegistry, _Ed25519EdDSA + + class _Registry(_AdcpJwsRegistry): + algorithms = { + "EdDSA": _Ed25519EdDSA(), + "Ed25519": JWSRegistry.algorithms["Ed25519"], + "PS512": JWSRegistry.algorithms["PS512"], + } + + return _Registry(algorithms=[alg], strict_check_header=False) + + +async def verify_wba_governance_issuer( + token: object, + *, + signer_keys: WbaSignerKeyResolver, + governance_agents: Collection[str], + now: float | None = None, + clock_skew_seconds: float = 60, +) -> str: + """Check a governance token's key and issuer; return the issuer origin. + + The two checks of ``wba-profile.mdx``, One agent per purpose: + + 1. the token's ``kid`` is in the key directory of the ``iss`` origin + (resolved through ``signer_keys``, keyed on origin and key), and + ``jku`` is that directory's URL (``governance_key_unknown``); + 2. the ``iss`` origin is the buyer's governance agent: one of + ``governance_agents``, the governance-typed ``agents[]`` entry in the + buyer's brand.json or the ``adcp:governance`` origin in its trust.json + (``governance_issuer_not_authorized``). + + In checklist order around them: the compact JWS and its ``alg`` and + ``typ`` (``governance_token_invalid``), the signature, verified by + ``joserfc`` (``governance_token_invalid``), and ``exp`` and ``iat`` with + ``clock_skew_seconds`` of skew (``governance_token_expired`` / + ``governance_token_not_yet_valid``). An unresolvable directory is + ``governance_jwks_unavailable``. Failures raise + :class:`WbaGovernanceError`. + + The rest of the governance checklist (``aud``, ``phase``, ``sub``, the + action binding, revocation, replay) is the caller's. + """ + from joserfc import jws as jose_jws + from joserfc.errors import BadSignatureError, JoseError + from joserfc.jwk import OKPKey, RSAKey + from joserfc.util import urlsafe_b64decode + + def fail(code: str, reason: str) -> WbaGovernanceError: + return WbaGovernanceError(code, reason) + + now = time.time() if now is None else now + if not isinstance(token, str) or not token or len(token) > 16384: + raise fail("governance_token_invalid", "governance token is missing or too large") + try: + compact = jose_jws.extract_compact( + token.encode("ascii"), registry=_governance_registry("EdDSA") + ) + header = parse_strict_json(urlsafe_b64decode(compact.segments["header"])) + claims = parse_strict_json(compact.payload) + except (JoseError, StrictJsonError, UnicodeError, ValueError) as exc: + raise fail("governance_token_invalid", "governance token is not a compact JWS") from exc + if not isinstance(header, dict) or not isinstance(claims, dict): + raise fail("governance_token_invalid", "governance token header or claims not an object") + alg = header.get("alg") + if alg not in {"EdDSA", "Ed25519", "PS512"}: + raise fail("governance_token_invalid", "governance token alg not allowed") + if header.get("typ") != WBA_GOVERNANCE_TYP: + raise fail("governance_token_invalid", "governance token typ mismatch") + try: + issuer = _https_origin(str(claims.get("iss", ""))) + except ValueError as exc: + raise fail("governance_token_invalid", "governance token iss is not an origin") from exc + if issuer != claims.get("iss") or header.get("jku") != issuer + DIRECTORY_PATH: + raise fail("governance_key_unknown", "jku is not the iss origin's key directory") + kid = header.get("kid") + if not isinstance(kid, str) or not kid: + raise fail("governance_key_unknown", "governance token kid is missing") + try: + key = await signer_keys(kid, signer_origin=issuer) + except WbaVerificationError as exc: + raise fail("governance_jwks_unavailable", "issuer key directory unavailable") from exc + try: + matches = ( + key is not None + and canonical_origin(key.agent_url) == issuer + and thumbprint_key_id(key.jwk) == kid + ) + key_alg = _algorithm(key.jwk) if key is not None and matches else None + except (ValueError, TypeError, AttributeError, WbaVerificationError): + matches, key_alg = False, None + if not matches or key is None or key_alg is None: + raise fail("governance_key_unknown", "kid is not in the iss origin's key directory") + if alg not in _GOVERNANCE_JWS_ALGS[key_alg]: + raise fail("governance_token_invalid", "governance token alg does not match the key") + public = {name: value for name, value in key.jwk.items() if name not in {"kid", "alg"}} + try: + jose_key = ( + OKPKey.import_key(public) if key_alg == ALG_ED25519 else RSAKey.import_key(public) + ) + jose_jws.deserialize_compact(token, jose_key, registry=_governance_registry(str(alg))) + except BadSignatureError as exc: + raise fail("governance_token_invalid", "governance token signature invalid") from exc + except JoseError as exc: + raise fail("governance_token_invalid", f"governance token rejected: {exc}") from exc + expires, issued = claims.get("exp"), claims.get("iat") + if type(expires) is not int or type(issued) is not int: + raise fail("governance_token_invalid", "governance token exp and iat must be integers") + if expires <= now - clock_skew_seconds: + raise fail("governance_token_expired", "governance token expired") + if issued > now + clock_skew_seconds: + raise fail("governance_token_not_yet_valid", "governance token issued in the future") + try: + authorized = {_https_origin(origin) for origin in governance_agents} + except ValueError as exc: + raise ValueError("governance_agents must be HTTPS origins") from exc + if issuer not in authorized: + raise fail("governance_issuer_not_authorized", "iss is not the buyer's governance agent") + return issuer + + class WbaRevocationStatusChecker(Protocol): """Report a WBA key's state in its origin's signed revocation list.""" @@ -1299,6 +1397,7 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: __all__ = [ + "ALG_ED25519", "ALG_RSA_PSS_SHA512", "DEFAULT_DIRECTORY_MAX_STALE_SECONDS", "DIRECTORY_MEDIA_TYPE", @@ -1307,9 +1406,12 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: "DiscoveredSignerKeys", "GENERIC_AUTHENTICATION_ERROR", "OriginRevocationChecker", + "WBA_ALGORITHMS", + "WBA_GOVERNANCE_TYP", "WBA_NONCE_BYTES", "WbaAuthorizationError", "WbaDiscoveryError", + "WbaGovernanceError", "WbaRevocationStatus", "WbaRevocationStatusChecker", "WbaRevokedKeyError", @@ -1325,6 +1427,7 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: "is_64_byte_nonce", "valid_wba_nonce", "verify_directory_response", + "verify_wba_governance_issuer", "verify_wba_request", "wba_revocation_status", ] diff --git a/src/adcp/signing/webhook_verifier.py b/src/adcp/signing/webhook_verifier.py index 4478dfd0e..03d542070 100644 --- a/src/adcp/signing/webhook_verifier.py +++ b/src/adcp/signing/webhook_verifier.py @@ -27,7 +27,8 @@ import httpx -from adcp.signing.canonical import _lookup, parse_signature_input_header, split_structured_field +from adcp.signing._rfc9421 import legacy_binary_tokens, mixes_base64_alphabets +from adcp.signing.canonical import _lookup, parse_signature_input_header from adcp.signing.constants import ( DEFAULT_SKEW_SECONDS, MAX_WINDOW_SECONDS, @@ -232,20 +233,13 @@ def _precheck_webhook_signature_alphabet(headers: Mapping[str, str], label: str) raw = _lookup(headers, "signature") if raw is None: return - for entry in split_structured_field(raw, ","): - name, separator, value = entry.strip().partition("=") - if not separator or name.strip() != label: - continue - value = value.strip() - if value.startswith(":") and value.endswith(":"): - token = value[1:-1] - if any(char in token for char in "+/") and any(char in token for char in "-_"): - raise SignatureVerificationError( - WEBHOOK_SIGNATURE_HEADER_MALFORMED, - step=1, - message="webhook Signature must not mix Base64 alphabets", - ) - return + for owner, token in legacy_binary_tokens(raw): + if owner == label and mixes_base64_alphabets(token): + raise SignatureVerificationError( + WEBHOOK_SIGNATURE_HEADER_MALFORMED, + step=1, + message="webhook Signature must not mix Base64 alphabets", + ) def _precheck_webhook_has_required_components(headers: Mapping[str, str]) -> None: diff --git a/tests/conformance/signing/test_crypto.py b/tests/conformance/signing/test_crypto.py index bc0da8c04..1f9604817 100644 --- a/tests/conformance/signing/test_crypto.py +++ b/tests/conformance/signing/test_crypto.py @@ -134,7 +134,7 @@ def test_extract_signature_rejects_base64url_for_3_2_profile() -> None: ) header = vector["request"]["headers"]["Signature"] - with pytest.raises(ValueError, match="RFC 8941 standard Base64"): + with pytest.raises(ValueError, match="malformed Structured Field"): extract_signature_bytes(header, allow_legacy_base64url=False) diff --git a/tests/conformance/signing/test_jws.py b/tests/conformance/signing/test_jws.py index a16804cda..e82012553 100644 --- a/tests/conformance/signing/test_jws.py +++ b/tests/conformance/signing/test_jws.py @@ -120,7 +120,8 @@ def resolve(keyid: str) -> dict[str, object] | None: def test_parse_compact_jws_splits_segments() -> None: # Build a fake compact-shaped string inline so the test literal isn't # a plausible-looking JWT (high-entropy-secret scanners flag otherwise). - header_b64 = b64url_encode(b"FAKE-HEADER") + # joserfc's extract_compact decodes the header, so it must be JSON with alg. + header_b64 = b64url_encode(b'{"alg":"FAKE"}') payload_b64 = b64url_encode(b"FAKE-PAYLOAD") sig_b64 = b64url_encode(b"FAKE-SIG") token = f"{header_b64}.{payload_b64}.{sig_b64}" diff --git a/tests/conformance/signing/test_parse_edge_cases.py b/tests/conformance/signing/test_parse_edge_cases.py index 9e4c87929..09b81c737 100644 --- a/tests/conformance/signing/test_parse_edge_cases.py +++ b/tests/conformance/signing/test_parse_edge_cases.py @@ -11,7 +11,6 @@ from adcp.signing.canonical import ( canonicalize_target_uri, parse_signature_input_header, - split_structured_field, ) @@ -27,10 +26,10 @@ def test_sfv_splitter_handles_escaped_backslash_before_closing_quote() -> None: assert labels["sig1"].params["keyid"] == "kid1" -def test_sfv_splitter_escape_preserved_quote_inside_string() -> None: +def test_escaped_quote_inside_string_does_not_terminate_the_value() -> None: # Escaped double-quote inside a quoted value must not terminate the span. - out = split_structured_field('a="quoted \\"inner\\" text";b=1', ";") - assert out == ['a="quoted \\"inner\\" text"', "b=1"] + labels = parse_signature_input_header('sig1=("@method");nonce="quoted \\"inner\\" text";b=1') + assert labels["sig1"].params == {"nonce": 'quoted "inner" text', "b": 1} def test_parse_rejects_unterminated_quoted_string() -> None: diff --git a/tests/conformance/signing/test_revocation_checker_boundary.py b/tests/conformance/signing/test_revocation_checker_boundary.py index 4bc2e01db..643c334bf 100644 --- a/tests/conformance/signing/test_revocation_checker_boundary.py +++ b/tests/conformance/signing/test_revocation_checker_boundary.py @@ -109,15 +109,16 @@ def run(): def _observe_crypto_and_replay(monkeypatch, replay, events): - from adcp.signing import verifier + from adcp.signing import _rfc9421 - original = verifier.verify_signature + original = _rfc9421.verify - def verify(**kwargs): + def verify(*args, **kwargs): events.append("callback_crypto") - return original(**kwargs) + return original(*args, **kwargs) - monkeypatch.setattr(verifier, "verify_signature", verify) + # The verifier's step 10 is http-message-signatures, reached through here. + monkeypatch.setattr(_rfc9421, "verify", verify) for name in ("seen", "remember", "claim", "at_capacity"): method = getattr(replay, name) diff --git a/tests/conformance/signing/test_rfc9421_library_contract.py b/tests/conformance/signing/test_rfc9421_library_contract.py new file mode 100644 index 000000000..cf9425cd3 --- /dev/null +++ b/tests/conformance/signing/test_rfc9421_library_contract.py @@ -0,0 +1,167 @@ +"""The http-message-signatures and http-sf shapes adcp.signing._rfc9421 relies on. + +The SDK takes RFC 9421 from ``http-message-signatures`` and RFC 8941 from +``http-sf``. It plugs in through public extension points (the +``component_resolver_class``, ``validate_created_and_expires``, +``signature_metadata_parameters``) and two protected subclass hooks +(``_build_signature_base``, ``_parse_covered_component_ids``). A library +release that moves any of them fails here, before it fails a profile test +with a confusing error code. +""" + +from __future__ import annotations + +import inspect + +import http_sf +import pytest +from cryptography.hazmat.primitives.asymmetric import ed25519 +from http_message_signatures import ( + HTTPMessageSigner, + HTTPMessageVerifier, + HTTPSignatureComponentResolver, + InvalidSignature, +) +from http_message_signatures.signatures import HTTPSignatureHandler + +from adcp.signing import _rfc9421 + + +def test_protected_hooks_keep_their_signatures(): + build = inspect.signature(HTTPSignatureHandler._build_signature_base) + assert list(build.parameters) == [ + "self", + "message", + "covered_component_ids", + "signature_params", + ] + parse = inspect.signature(HTTPMessageSigner._parse_covered_component_ids) + assert list(parse.parameters) == ["self", "covered_component_ids"] + init = inspect.signature(HTTPSignatureHandler.__init__) + assert "component_resolver_class" in init.parameters + assert callable(HTTPMessageVerifier.validate_created_and_expires) + assert HTTPSignatureHandler.signature_metadata_parameters == { + "alg", + "created", + "expires", + "keyid", + "nonce", + "tag", + } + + +def test_resolver_receives_nodes_with_value_and_params(): + seen = [] + + class Recording(_rfc9421.ComponentResolver): + def resolve(self, component_node): + seen.append((component_node.value, dict(component_node.params))) + return super().resolve(component_node) + + message = _rfc9421.Request( + "GET", "https://example.com/", {"Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c)"} + ) + base = _rfc9421.build_base( + message, + covered_component_ids=['"example-dict";key="b"', '"@authority"'], + params={"created": 1}, + resolver=Recording, + ) + assert seen == [("example-dict", {"key": "b"}), ("@authority", {})] + # RFC 9421 section 2.1.2: the member value with its parameters. + assert base.decode().splitlines()[0] == '"example-dict";key="b": 2;x=1;y=2' + + +def test_sign_emits_the_parameter_order_signature_base_reproduces(): + key = ed25519.Ed25519PrivateKey.generate() + covered = ['"@method"', '"@authority"'] + signed = _rfc9421.Request("POST", "https://example.com/p") + signature_input, signature = _rfc9421.sign( + signed, + alg="ed25519", + private_key=key, + key_id="k", + label="sig1", + created=10, + expires=20, + nonce="n", + tag="t", + covered_component_ids=covered, + resolver=_rfc9421.ComponentResolver, + ) + assert signature_input == ( + 'sig1=("@method" "@authority");created=10;keyid="k";alg="ed25519";' + 'expires=20;nonce="n";tag="t"' + ) + base, member = _rfc9421.signature_base( + _rfc9421.Request("POST", "https://example.com/p"), + alg="ed25519", + key_id="k", + created=10, + expires=20, + nonce="n", + tag="t", + covered_component_ids=covered, + resolver=_rfc9421.ComponentResolver, + ) + assert signature_input == f"sig1={member}" + assert _rfc9421.parse_dictionary(signature)["sig1"][0] == key.sign(base) + + +def test_verify_chains_the_component_error_and_the_crypto_error(): + key = ed25519.Ed25519PrivateKey.generate() + message = _rfc9421.Request("POST", "https://example.com/p", {"x-a": "1"}) + signature_input, signature = _rfc9421.sign( + message, + alg="ed25519", + private_key=key, + key_id="k", + label="sig1", + created=10, + expires=20, + nonce="n", + tag="t", + covered_component_ids=['"x-a"'], + resolver=_rfc9421.ComponentResolver, + ) + fields = {"Signature-Input": signature_input, "Signature": signature} + verifier = _rfc9421.ProfileVerifier( + signature_algorithm=_rfc9421.ALGORITHMS["ed25519"], + key_resolver=_rfc9421._Key(public_key=key.public_key()), + component_resolver_class=_rfc9421.ComponentResolver, + ) + with pytest.raises(InvalidSignature) as missing: + verifier.verify(_rfc9421.Request("POST", "https://example.com/p", fields), expect_tag="t") + assert isinstance(missing.value.__cause__, _rfc9421.ComponentError) + with pytest.raises(InvalidSignature) as tampered: + verifier.verify( + _rfc9421.Request("POST", "https://example.com/p", {**fields, "x-a": "2"}), + expect_tag="t", + ) + from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature + + assert isinstance(tampered.value.__cause__, CryptoInvalidSignature) + # The library's own window check is off: the profile checks its window. + verifier.verify( + _rfc9421.Request("POST", "https://example.com/p", {**fields, "x-a": "1"}), expect_tag="t" + ) + + +def test_library_resolver_still_lacks_key_and_req(): + """pyauth/http-message-signatures#29: drop the key/req branches once fixed.""" + resolver = HTTPSignatureComponentResolver( + _rfc9421.Request("GET", "https://example.com/", {"d": "a=1, b=2"}) + ) + node = HTTPMessageSigner._parse_covered_component_ids(None, ['"d";key="a"'])[0] + if resolver.resolve(node) == "1": # pragma: no cover - the upstream fix landed + pytest.fail("the library now resolves ;key; remove the ComponentResolver branch") + + +def test_http_sf_reports_every_duplicate(): + calls = [] + http_sf.parse( + b'a=1, a=2, b=("x";p=1;p=2);q=1;q=2', + tltype="dictionary", + on_duplicate_key=lambda key, context: calls.append((key, context)), + ) + assert calls == [("a", "dictionary"), ("p", "parameter"), ("q", "parameter")] diff --git a/tests/conformance/signing/test_signing_provider.py b/tests/conformance/signing/test_signing_provider.py index 63e9d057d..ff709a855 100644 --- a/tests/conformance/signing/test_signing_provider.py +++ b/tests/conformance/signing/test_signing_provider.py @@ -368,7 +368,7 @@ def test_sign_request_rejects_key_id_with_control_characters() -> None: key_id would otherwise produce a Signature-Input header with a literal line break — a header-injection vector at non-httpx integrators.""" private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="key_id contains character"): + with pytest.raises(ValueError, match="disallowed characters"): sign_request( method="POST", url="https://seller.example.com/x", @@ -383,7 +383,7 @@ def test_sign_request_rejects_key_id_with_control_characters() -> None: def test_sign_request_rejects_key_id_with_non_ascii() -> None: private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="key_id contains character"): + with pytest.raises(ValueError, match="disallowed characters"): sign_request( method="POST", url="https://seller.example.com/x", @@ -401,7 +401,7 @@ def test_sign_request_rejects_label_with_crlf() -> None: headers; a CRLF here would inject extra header bytes. RFC 8941 §3.1.2 sf-keys are restricted to a token grammar.""" private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="not a valid RFC 8941 sf-key"): + with pytest.raises(ValueError, match="not a valid RFC 8941 key"): sign_request( method="POST", url="https://seller.example.com/x", @@ -418,7 +418,7 @@ def test_sign_request_rejects_label_with_crlf() -> None: def test_sign_request_rejects_label_starting_with_uppercase() -> None: """RFC 8941 §3.1.2 sf-keys must start with lowercase letter or '*'.""" private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="must start with a lowercase letter"): + with pytest.raises(ValueError, match="not a valid RFC 8941 key"): sign_request( method="POST", url="https://seller.example.com/x", @@ -462,7 +462,7 @@ def test_pem_to_adcp_jwk_rejects_rsa_public_pem() -> None: def test_sign_request_rejects_tag_with_control_characters() -> None: private_key = private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") - with pytest.raises(ValueError, match="tag contains character"): + with pytest.raises(ValueError, match="disallowed characters"): sign_request( method="POST", url="https://seller.example.com/x", diff --git a/tests/conformance/signing/test_structured_field_duplicates.py b/tests/conformance/signing/test_structured_field_duplicates.py index 0661ec1fe..0b7b40e5d 100644 --- a/tests/conformance/signing/test_structured_field_duplicates.py +++ b/tests/conformance/signing/test_structured_field_duplicates.py @@ -19,7 +19,7 @@ verify_request_signature, verify_wba_request, ) -from adcp.signing._structured_fields import reject_dictionary_duplicates +from adcp.signing._rfc9421 import parse_dictionary as reject_dictionary_duplicates from adcp.signing.canonical import parse_signature_input_header from adcp.signing.crypto import extract_signature_bytes from adcp.signing.wba import WbaVerificationError, _dictionary, uses_web_bot_auth diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index 8169c7e8f..a02812968 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -15,16 +15,16 @@ 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._rfc9421 import Request, parse_dictionary from adcp.signing.wba import ( ALG_RSA_PSS_SHA512, DirectoryResponse, DiscoveredSignerKeys, WbaVerificationError, _algorithm, - _base, + _signature_base, _verify_crypto, is_64_byte_nonce, public_directory_key, @@ -80,32 +80,23 @@ async def fetch(url, *, allow_private=False): 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() + label = next(iter(parse_dictionary(request["headers"]["signature-input"]))) + base = _signature_base(label, request["method"], request["url"], request["headers"]).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 - ) + keys = verify_directory_response(origin, _directory(fixture), now=fixture["reference_now"]) 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"] + _components, params = parse_dictionary(fixture["request"]["headers"]["signature-input"])["sig1"] + nonce = params["nonce"] assert nonce.endswith("==") and is_64_byte_nonce(nonce) and valid_wba_nonce(nonce) @@ -131,10 +122,10 @@ async def test_python_signed_requests_verify_with_the_target_uri_as_sent(path): created=1790841610, ) ) - member = _parse_dictionary(headers["Signature-Input"])["sig1"] + _components, params = 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 len(params["nonce"]) == 86 and is_64_byte_nonce(params["nonce"]) + base = _signature_base("sig1", "POST", url, headers).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) @@ -211,19 +202,27 @@ def test_rsa_pss_appendix_e_vector(): 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) + headers = { + "signature-agent": 'agent2="https://signature-agent.test"', + "signature-input": APPENDIX_E_INPUT, + "signature": APPENDIX_E_SIGNATURE, + } + base = _signature_base("sig2", "GET", "https://example.com/", headers) 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) + _components, params = parse_dictionary(APPENDIX_E_INPUT)["sig2"] + signature = parse_dictionary(APPENDIX_E_SIGNATURE)["sig2"] + message = Request("GET", "https://example.com/", headers) + assert ( + _verify_crypto(message, label="sig2", params=params, signature=signature, jwk=jwk) + == ALG_RSA_PSS_SHA512 + ) + tampered = Request("GET", "https://example.net/", headers) with pytest.raises(WbaVerificationError, match="did not verify"): - _verify_crypto(member, signature, jwk, tampered) + _verify_crypto(tampered, label="sig2", params=params, signature=signature, jwk=jwk) async def test_rsa_pss_identity_round_trip_and_key_bounds(): diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 57f4abaf6..2b0a5eaae 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -1,7 +1,8 @@ -"""Draft WBA vectors and the security boundaries of the experimental profile.""" +"""WBA profile vectors (adcp#7894) and the security boundaries of the experimental profile.""" from __future__ import annotations +import hashlib import json from dataclasses import replace from pathlib import Path @@ -42,6 +43,32 @@ ROOT = Path(__file__).parents[1] / "vectors" / "wba-profile" KEYS = {key["kid"]: key for key in json.loads((ROOT / "keys.json").read_text())["keys"]} VECTORS = sorted((ROOT / "positive").glob("*.json")) + sorted((ROOT / "negative").glob("*.json")) +GOVERNANCE_VECTORS = sorted((ROOT / "governance").glob("*.json")) + + +def _public(kid): + return {k: v for k, v in KEYS[kid].items() if not k.startswith("_")} + + +def _vector_resolver(vector): + async def resolve(keyid, *, signer_origin=None): + ids = vector["directories"].get(signer_origin, {}).get("keys_ref", []) + return ResolvedSignerKey(signer_origin, _public(keyid)) if keyid in ids else None + + return resolve + + +def test_vendored_vectors_match_recorded_provenance(): + upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) + assert upstream["commit"] == "e24eeb090c4365707641099cbbb45f9945c6d6e0" + vendored = { + str(path.relative_to(ROOT)) + for path in ROOT.rglob("*") + if path.is_file() and path.name not in {"README.md", "UPSTREAM.json"} + } + assert vendored == set(upstream["sha256"]) + for name, digest in upstream["sha256"].items(): + assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest async def _not_revoked(origin, keyid): @@ -111,24 +138,21 @@ async def fetch(url, *, allow_private=False): @pytest.mark.parametrize("path", VECTORS, ids=lambda p: f"{p.parent.name}/{p.name}") -async def test_draft_vectors(path): +async def test_profile_vectors(path): vector = json.loads(path.read_text()) request = vector["request"] if "response" in vector: response = vector["response"] + origin = request["url"].split("/.well-known/")[0] result = verify_directory_response( - request["url"], + origin, DirectoryResponse(response["body"].encode(), response["headers"]), now=vector["reference_now"], ) assert set(result) <= set(KEYS) + assert set(vector["expected_outcome"]["identities"].values()) == {origin} return - async def resolve(keyid, *, signer_origin=None): - ids = vector["directories"].get(signer_origin, {}).get("keys_ref", []) - public = {k: v for k, v in KEYS[keyid].items() if not k.startswith("_")} - return ResolvedSignerKey(signer_origin, public) if keyid in ids else None - replay = InMemoryReplayStore() for entry in vector.get("test_harness_state", {}).get("replay_cache_entries", []): replay.claim(entry["identity"], entry["nonce"], entry["ttl_seconds"]) @@ -139,42 +163,118 @@ async def verify(): url=request["url"], headers=request["headers"], body=request["body"].encode(), - signer_keys=resolve, + signer_keys=_vector_resolver(vector), 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"] if expected["success"]: signers = await verify() assert {s.label: s.agent_url for s in signers} == expected["identities"] + return + with pytest.raises(WbaVerificationError) as caught: + await verify() + assert caught.value.status == expected["status"] + challenge = expected.get("accept_signature") + if challenge is None: + assert "Accept-Signature" not in caught.value.headers + return + # The Accept-Signature member names the label, components and parameters. + from adcp.signing.wba import _dictionary + + member = _dictionary(caught.value.headers["Accept-Signature"])[challenge["label"]] + components, params = member + assert "(" + " ".join(_identifier(item) for item in components) + ")" == challenge["components"] + assert list(params) == challenge["parameters"] + assert params["tag"] == challenge["tag"] + if challenge.get("nonce") == "verifier-chosen": + assert isinstance(params["nonce"], str) and len(params["nonce"]) == 86 else: - with pytest.raises(WbaVerificationError) as caught: - await verify() - assert caught.value.status == expected["status"] - if expected["status"] == 403: - assert "Accept-Signature" in caught.value.headers + assert params["nonce"] is True + + +def _identifier(item): + from adcp.signing._rfc9421 import component_identifier + + return component_identifier(item) + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: f"{p.parent.name}/{p.name}") +def test_profile_vector_signature_bases(path): + """Our signature base matches every vector's, built by http-message-signatures.""" + from adcp.signing.wba import _signature_base + + vector = json.loads(path.read_text()) + message = vector.get("response", vector["request"]) + for label, expected in vector.get("expected_signature_base", {}).items(): + base = _signature_base( + label, + vector["request"]["method"], + vector["request"]["url"], + message["headers"], + response="response" in vector, + ) + assert base.decode() == expected + + +@pytest.mark.parametrize("path", GOVERNANCE_VECTORS, ids=lambda p: p.name) +async def test_governance_vectors(path): + from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer + + vector = json.loads(path.read_text()) + + async def verify(): + return await verify_wba_governance_issuer( + vector["jws"], + signer_keys=_vector_resolver(vector), + governance_agents=vector["role_listing"]["adcp:governance"], + now=vector["reference_now"], + ) + + expected = vector["expected_outcome"] + if expected["success"]: + assert await verify() == expected["issuer"] + return + with pytest.raises(WbaGovernanceError) as caught: + await verify() + assert caught.value.code == expected["error_code"] def test_signer_reproduces_independent_vector(): + """Signing the committed base with the committed seed gives the committed signature.""" + from adcp.signing._rfc9421 import parse_dictionary + from adcp.signing.crypto import sign_signature_base + vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) request = vector["request"] - key = next(iter(KEYS.values())) + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + private = private_key_from_jwk(key, d_field="_private_d_for_test_only") + signature = sign_signature_base( + alg="ed25519", + private_key=private, + signature_base=vector["expected_signature_base"]["sig1"].encode(), + ) + assert parse_dictionary(request["headers"]["Signature"])["sig1"][0] == signature + # sign_wba_request produces the same covered components, Signature-Agent + # and Content-Digest; the library orders the parameters its own way, so + # its base differs from the vector's only in @signature-params. result = sign_wba_request( method=request["method"], url=request["url"], headers={"Content-Type": "application/json"}, body=request["body"].encode(), - private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + private_key=private, agent_origin=key["identity"], created=1790841600, nonce="Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA", ) - assert all(request["headers"][name] == value for name, value in result.items()) + for name in ("Signature-Agent", "Content-Digest"): + assert result[name] == request["headers"][name] + ours = parse_dictionary(result["Signature-Input"])["sig1"] + theirs = parse_dictionary(request["headers"]["Signature-Input"])["sig1"] + assert ours[0] == theirs[0] and ours[1] == theirs[1] @pytest.mark.parametrize( @@ -314,9 +414,10 @@ async def resolve(keyid, *, signer_origin=None): revocation_checker=_not_revoked, now=1001, ) - # A reused nonce is 429 with no re-sign challenge. + # A reused nonce is 429 with Accept-Signature carrying a fresh + # nonce (wba-profile.mdx, Error responses). assert replayed.value.status == 429 - assert "Accept-Signature" not in replayed.value.headers + assert 'nonce="' in replayed.value.headers["Accept-Signature"] async def test_framework_requires_explicit_binding_before_dispatch(): @@ -527,53 +628,65 @@ async def fetch(url, *, allow_private=False): await resolver(jwk["kid"], signer_origin="https://legacy.com") -async def test_p256_wba_derives_optional_algorithm_and_signs_multikey_directory(): +async def test_es256_is_not_a_web_bot_auth_algorithm(): + """The profile allows ed25519 and rsa-pss-sha512; ES256 stays on RFC 9421.""" from cryptography.hazmat.primitives.asymmetric import ec - from adcp.signing.crypto import format_signature_header, sign_signature_base - from adcp.signing.wba import _base, _dictionary + from adcp.signing.crypto import b64url_encode ed_key, ec_key = ed25519.Ed25519PrivateKey.generate(), ec.generate_private_key(ec.SECP256R1()) - response = sign_key_directory( - agent_origin="https://buyer.com", private_keys=[ed_key, ec_key], created=1000 - ) - keys = verify_directory_response("https://buyer.com", response, now=1001) - assert len(keys) == 2 - headers = {"Content-Type": "application/json"} - headers.update( + with pytest.raises(ValueError, match="Ed25519 and RSA"): + sign_key_directory(agent_origin="https://buyer.com", private_keys=[ec_key], created=1000) + with pytest.raises(ValueError, match="Ed25519 and RSA"): sign_wba_request( method="POST", url="https://seller.com/mcp", - headers=headers, + headers={"Content-Type": "application/json"}, body=b"{}", private_key=ec_key, agent_origin="https://buyer.com", created=1000, ) + # A directory may list a P-256 key for another use; it is skipped, and the + # signed Ed25519 key is the only one returned. + response = sign_key_directory( + agent_origin="https://buyer.com", private_keys=[ed_key], created=1000 ) - inputs = _dictionary(headers["Signature-Input"]) - member = inputs["sig1"] - del member.params["alg"] - headers["Signature-Input"] = str(inputs) - signature = sign_signature_base( - alg="ecdsa-p256-sha256", - private_key=ec_key, - signature_base=_base("POST", "https://seller.com/mcp", headers, member), - ) - headers["Signature"] = format_signature_header( - signature, label="sig1", use_legacy_base64url=False - ) - result = await verify_wba_request( - method="POST", - url="https://seller.com/mcp", - headers=headers, - body=b"{}", - signer_keys=StaticSignerKeys({"https://buyer.com": {"keys": list(keys.values())}}), - replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, - now=1001, + numbers = ec_key.public_key().public_numbers() + p256 = { + "kty": "EC", + "crv": "P-256", + "x": b64url_encode(numbers.x.to_bytes(32, "big")), + "y": b64url_encode(numbers.y.to_bytes(32, "big")), + } + with pytest.raises(WbaVerificationError, match="unsupported directory key"): + from adcp.signing.wba import _algorithm + + _algorithm(p256) + keys = verify_directory_response("https://buyer.com", response, now=1001) + assert list(keys) == [public_directory_key(ed_key)["kid"]] + + +async def test_unsigned_directory_is_refused(): + """Signed directory responses are required (WBA Appendix B.1).""" + key = ed25519.Ed25519PrivateKey.generate() + signed = sign_key_directory(agent_origin="https://buyer.com", private_keys=[key], created=1000) + unsigned = DirectoryResponse( + signed.body, + {k: v for k, v in signed.headers.items() if k not in {"Signature", "Signature-Input"}}, ) - assert result[0].alg == "ecdsa-p256-sha256" + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response("https://buyer.com", unsigned, now=1001) + + async def fetch(url, *, allow_private=False): + return unsigned + + with pytest.raises(WbaVerificationError): + await DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1001)( + public_directory_key(key)["kid"], signer_origin="https://buyer.com" + ) + with pytest.raises(TypeError): + DiscoveredSignerKeys(require_signed_directories=True) # type: ignore[call-arg] @pytest.mark.parametrize( @@ -607,56 +720,115 @@ def test_legacy_verifier_never_attributes_or_emits_legacy_codes_for_wba(headers) resolver.assert_not_called() -@pytest.mark.parametrize("cover_extra", [False, True]) -async def test_relay_covers_every_component_of_earlier_signature(cover_extra): - from http_sfv.item import Item - - from adcp.signing.crypto import sign_signature_base - from adcp.signing.wba import _base, _dictionary +@pytest.mark.parametrize("change", ["second_agent_member", "short_nonce", "padded_nonce"]) +async def test_one_signer_and_64_byte_nonce(change): + """One Signature-Agent member per request, and a nonce of exactly 64 bytes.""" + import base64 - vector = json.loads((ROOT / "positive/002-relay-request-two-signatures.json").read_text()) + vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) request = vector["request"] - headers = request["headers"] - headers["X-Buyer-Reference"] = "campaign-123" - inputs = _dictionary(headers["Signature-Input"]) - signatures = _dictionary(headers["Signature"]) - labels = list(inputs) - inputs[labels[0]].append(Item("x-buyer-reference")) - if cover_extra: - inputs[labels[1]].append(Item("x-buyer-reference")) - headers["Signature-Input"] = str(inputs) - for label, member in inputs.items(): - key = KEYS[member.params["keyid"]] - signatures[label] = Item( - sign_signature_base( - alg="ed25519", - private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), - signature_base=_base(request["method"], request["url"], headers, member), + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + private = private_key_from_jwk(key, d_field="_private_d_for_test_only") + if change == "short_nonce": + with pytest.raises(ValueError, match="64 bytes"): + sign_wba_request( + method="POST", + url=request["url"], + headers={"Content-Type": "application/json"}, + body=request["body"].encode(), + private_key=private, + agent_origin=key["identity"], + created=1790841600, + nonce="A" * 22, ) + return + nonce = ( + base64.b64encode(bytes(range(64))).decode() + if change == "padded_nonce" + else base64.urlsafe_b64encode(b"\x07" * 64).decode().rstrip("=") + ) + headers = {"Content-Type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url=request["url"], + headers=headers, + body=request["body"].encode(), + private_key=private, + agent_origin=key["identity"], + created=1790841600, + nonce=nonce, ) - headers["Signature"] = str(signatures) - - async def resolve(keyid, *, signer_origin=None): - key = KEYS[keyid] - return ResolvedSignerKey(signer_origin, key) if key["identity"] == signer_origin else None + ) + if change == "second_agent_member": + headers["Signature-Agent"] += ', sig2="https://agent.brand-7k3q.com"' async def verify(): return await verify_wba_request( - method=request["method"], + method="POST", url=request["url"], headers=headers, body=request["body"].encode(), - signer_keys=resolve, + signer_keys=_vector_resolver(vector), replay_store=InMemoryReplayStore(), - revocation_checker=_not_revoked, now=vector["reference_now"], ) - if cover_extra: - assert len(await verify()) == 2 - else: - with pytest.raises(WbaVerificationError, match="covered component"): - await verify() + if change == "padded_nonce": + # Cloudflare's signer sends padded standard base64 of 64 bytes. + (signer,) = await verify() + assert signer.agent_url == key["identity"] + return + with pytest.raises(WbaVerificationError, match="one web-bot-auth signature") as caught: + await verify() + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers + + +async def test_short_received_nonce_is_a_challenge(): + """A nonce shorter than 64 bytes fails the profile: 403 with Accept-Signature.""" + from adcp.signing import _rfc9421 + from adcp.signing.wba import _WbaComponentResolver + + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + private = private_key_from_jwk(key, d_field="_private_d_for_test_only") + vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) + request = vector["request"] + headers = {k: v for k, v in request["headers"].items() if not k.startswith("Signature-I")} + headers = {k: v for k, v in headers.items() if k != "Signature"} + signature_input, signature = _rfc9421.sign( + _rfc9421.Request(request["method"], request["url"], dict(headers)), + alg="ed25519", + private_key=private, + key_id=key["kid"], + label="sig1", + created=1790841600, + expires=1790841900, + nonce="A" * 22, + tag="web-bot-auth", + covered_component_ids=[ + '"@method"', + '"@target-uri"', + '"@authority"', + '"content-type"', + '"content-digest"', + '"signature-agent";key="sig1"', + ], + resolver=_WbaComponentResolver, + ) + headers.update({"Signature-Input": signature_input, "Signature": signature}) + with pytest.raises(WbaVerificationError, match="nonce") as caught: + await verify_wba_request( + method=request["method"], + url=request["url"], + headers=headers, + body=request["body"].encode(), + signer_keys=_vector_resolver(vector), + replay_store=InMemoryReplayStore(), + now=vector["reference_now"], + ) + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers @pytest.mark.parametrize( diff --git a/tests/conformance/signing/test_webhook_rc4_vectors.py b/tests/conformance/signing/test_webhook_rc4_vectors.py index 362feda50..6571de0c5 100644 --- a/tests/conformance/signing/test_webhook_rc4_vectors.py +++ b/tests/conformance/signing/test_webhook_rc4_vectors.py @@ -123,7 +123,7 @@ def test_mixed_alphabet_stops_before_lookup_crypto_or_replay(signature, monkeypa def forbidden(*args, **kwargs): pytest.fail("malformed signature reached key lookup, crypto or replay state") - monkeypatch.setattr("adcp.signing.verifier.verify_signature", forbidden) + monkeypatch.setattr("adcp.signing._rfc9421.verify", forbidden) for method in ("seen", "remember", "claim", "at_capacity"): monkeypatch.setattr(options.replay_store, method, forbidden) options = replace(options, jwks_resolver=forbidden) diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index 55045441e..d182feff9 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,9 +1,12 @@ -# Experimental Web Bot Auth vectors +# Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`656bd080f01cc2fca4a1e623af44e96cfd827ba5`, -`static/compliance/source/test-vectors/wba-profile/` on 2026-10-13. +`e24eeb090c4365707641099cbbb45f9945c6d6e0`, +`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-16. The +upstream `README.md` is vendored as `UPSTREAM-README.md`. `UPSTREAM.json` +records each file's SHA-256, and +`tests/conformance/signing/test_wba_profile.py` checks them. -These are draft transport vectors, not released AdCP 3.3 compliance inputs. -Governance JWS remains on the RFC 9421 chain and is outside this preview. -The public private seeds in keys.json are test inputs only. +The specification is `docs/building/by-layer/L1/wba-profile.mdx` at the same +commit (proposed for AdCP 3.3). The public private seeds in `keys.json` are +test inputs only. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md new file mode 100644 index 000000000..6aefc5dd8 --- /dev/null +++ b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md @@ -0,0 +1,154 @@ +# Web Bot Auth profile conformance vectors + +Test vectors for the proposed AdCP profile of Web Bot Auth (WBA) for request and webhook signing. The profile is proposed for AdCP 3.3 in [RFC #7878](https://github.com/adcontextprotocol/adcp/issues/7878) and is published alongside the AdCP RFC 9421 request-signing profile. These fixtures let a signer written with one WBA or RFC 9421 library and a verifier written with another agree on the wire format and on what a verifier rejects. + +Specification: [Web Bot Auth request-signing profile](https://adcontextprotocol.org/docs/building/by-layer/L1/wba-profile) in `docs/building/by-layer/L1/wba-profile.mdx`. Section numbers in vector comments refer to [draft-ietf-webbotauth-httpsig-protocol-00](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/00/) unless they name RFC 9421. + +**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/wba-profile/`, with `{version}` either an immutable release or `latest`. Tree preserved: `keys.json`, `positive/*.json`, `negative/*.json`, and `governance/*.json` are all resolvable. + +## Test keys are public + +`keys.json` publishes the private seed of every test key in `_private_d_for_test_only`, so implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string `rfc004-test/`, used as the Ed25519 private key. Each `kid` is the key's JWK thumbprint (RFC 7638; RFC 8037 Appendix A.3 for Ed25519), and each entry's `identity` is the origin whose key directory publishes the key. + +**These keys are valid only for grading against this suite.** WBA section 6.8: test keys "MUST NOT be used in production", and verifiers SHOULD reject them when detected. A production verifier that accepts a directory containing `6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I`, `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI`, or `1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE` is exploitable. + +The domains in the vectors (`buyer-7k3q.com`, `agent.brand-7k3q.com`, `governance-7k3q.com`, `seller-7k3q.com`) are placeholders that resolve to nothing. A harness serves each directory from the vector's `directories` object instead of fetching it. + +## Scope + +The vectors exercise the profile rules: the covered `Signature-Agent` member, key selection by thumbprint from the directory the member names, the required `nonce`, replay rejection, one signature per request, the signed directory response, the `400`, `403`, and `429` responses, and the two checks on a governance token: its key is in the issuer's directory, and its issuer is the buyer's governance agent. They do not exercise live directory fetches, revocation-list polling, or onboarding state, which need live endpoints and belong in integration suites. + +## File layout + +``` +test-vectors/wba-profile/ +├── README.md this file +├── keys.json three Ed25519 test keys, one per identity, with private seeds +├── positive/ requests and responses that MUST verify +│ ├── 001-single-signer-request.json buyer agent signs create_media_buy +│ └── 002-signed-directory-response.json directory response signed per key (WBA Appendix B.1) +├── negative/ requests a verifier MUST reject, with the expected status +│ ├── 001-missing-nonce.json → 403 with Accept-Signature (nonce required on every signed request) +│ ├── 002-replayed-nonce.json → 429 with Accept-Signature carrying a fresh nonce (needs test_harness_state) +│ ├── 003-signature-agent-not-covered.json → 403 (Signature-Agent present but not a covered component) +│ ├── 004-key-not-in-named-directory.json → 403 (key is published by an origin other than the one named) +│ ├── 005-malformed-signature-agent.json → 400 (Signature-Agent does not parse) +│ └── 006-two-signatures.json → 403 (two web-bot-auth signatures on one request, each valid on its own) +└── governance/ governance tokens: one agent per purpose + ├── 001-governance-token.json governance agent, its own key → verifies + ├── 002-governance-token-key-not-in-issuer-directory.json → governance_key_unknown + └── 003-governance-token-from-non-governance-agent.json → governance_issuer_not_authorized +``` + +## Vector format + +Every request vector has this shape: + +```json +{ + "name": "human-readable description", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#anchor", + "signing_profile": "web-bot-auth", + "reference_now": 1790841610, + "request": { + "method": "POST", + "url": "https://seller-7k3q.com/mcp/", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:...:", + "Signature-Agent": "sig1=\"https://buyer-7k3q.com\"", + "Signature-Input": "sig1=(...);created=...;expires=...;nonce=\"...\";keyid=\"...\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "Signature": "sig1=:...:" + }, + "body": "{...}" + }, + "directories": { + "https://buyer-7k3q.com": { "keys_ref": ["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] } + }, + "test_harness_state": { + "replay_cache_entries": [{ "identity": "https://buyer-7k3q.com", "nonce": "...", "ttl_seconds": 360 }] + }, + "expected_signature_base": { "sig1": "\"@method\": POST\n..." }, + "expected_outcome": { + "success": false, + "status": 403, + "accept_signature": { + "label": "sig1", + "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", + "parameters": ["created", "expires", "nonce", "keyid", "alg", "tag"], + "tag": "web-bot-auth" + }, + "reason": "..." + }, + "$comment": "free-form notes" +} +``` + +### Fields + +- **`name`**: one-line description. +- **`spec_reference`**: the section of `wba-profile.mdx` the vector grades. +- **`signing_profile`**: always `web-bot-auth`. A verifier selects these vectors by this value and never grades them as AdCP RFC 9421 profile cases. +- **`reference_now`**: Unix seconds. Inject this as the verifier's wall clock instead of using the real time. +- **`request`**: the raw HTTP request the verifier receives. Header names are case-insensitive; `body` is the exact byte string on the wire. Binary header values use RFC 8941 `sf-binary` (standard Base64 with padding). +- **`response`**: present on the directory-response vector only. The response the directory server sends to `request`, with `status`, `headers`, and the exact `body`. +- **`directories`**: the key directory each origin serves, as a list of `kid` values from `keys.json`. The harness answers a fetch of `/.well-known/http-message-signatures-directory` with a JWK Set holding exactly those keys. An origin absent from the object serves no directory. Several vectors list more than one origin so that a harness can show the trap the vector guards against (see `004-key-not-in-named-directory`). +- **`test_harness_state`**: optional. Preloads verifier state before verification. `replay_cache_entries` lists `{ identity, nonce, ttl_seconds }` pairs the verifier has already accepted. Reset verifier state before every vector. +- **`expected_signature_base`**: keyed by the signature's label, the signature base per RFC 9421 section 2.5. Lines are joined with a single `\n`, there is no trailing newline, and components appear in the order listed in `Signature-Input`, followed by `@signature-params`. The base is the one a verifier computes from the request as sent. `006-two-signatures` carries one base per label, so a harness can show that each signature verifies on its own. Diff your computed base against this field before looking at signatures. +- **`expected_outcome.success`**: `true` for positive vectors, `false` for negative. +- **`expected_outcome.identities`**: positive vectors only. Keyed by the signature's label, the origin the verifier attributes the signature to. +- **`expected_outcome.status`**: negative vectors only. The HTTP status the verifier answers: `400` when the signature headers do not parse, `403` when a signature or the profile's checks fail, and `429` when the signature or its nonce was used before. Conformance requires this status. +- **`expected_outcome.accept_signature`**: negative vectors with `403` or `429`. The `Accept-Signature` member the verifier sends (RFC 9421 section 5.1): the label, the covered components, and the parameters the profile requires. `"nonce": "verifier-chosen"` marks the replay case, where the verifier supplies a fresh nonce value that this vector does not pin. The exact serialization is RFC 9421 section 5.1; conformance grades the status, and a harness MAY check that `Accept-Signature` names at least these components and parameters. +- **`expected_outcome.reason`**: the profile rule the request breaks. Informational. +- **`$comment`**: free-form notes, including which non-conformant verifier behavior the vector catches. + +### Governance vectors + +Each vector under `governance/` carries a compact JWS instead of a request: + +- **`document_type`**: `governance_context`. +- **`jws`**: the token as a verifier receives it. `decoded` repeats its header and payload for reading. +- **`directories`**: as for request vectors. The verifier resolves the key from the directory of the `iss` origin, which `jku` names. +- **`role_listing`**: the origins listed in each role by a record the verifier already trusts. In deployment, that record is the governance-typed `agents[]` entry in the buyer's `brand.json`, or the `adcp:governance` role in `trust.json` once #7809 lands. +- **`expected_outcome`**: `issuer` for the positive vector; for a negative vector, the `error_code` from the governance error table in `security.mdx`. + +The vectors grade the two governance checks. The two negative vectors each fail one check: `002` the key lookup in the `iss` origin's directory, and `003` the check that the issuer is the buyer's governance agent. The positive vector passes the remaining checks a verifier runs on the way (`alg`, `typ`, `jku` equal to the `iss` origin's directory, the signature, `exp` and `iat`), but no vector here fails them. The other checklist steps (`aud`, `phase`, `sub`, action binding, revocation, replay) are out of scope. + +## Conformance expectations + +An implementation is conformant when, for every vector: + +1. **Positive request vectors** verify the `web-bot-auth` signature and attribute it to the origin in `expected_outcome.identities`. +2. **The directory-response vector** verifies the response signature with the key the directory body provides and matches `Content-Digest` against the body. +3. **Negative vectors** answer `expected_outcome.status`. A `403` carries `Accept-Signature`. +4. **Governance vectors** verify `001` and attribute it to `expected_outcome.issuer`, and reject `002` and `003` with `expected_outcome.error_code`. +5. **Signature bytes on positive vectors** match the committed `Signature` values byte for byte when the implementation signs the committed `expected_signature_base` with the corresponding private seed. Ed25519 is deterministic. + +Several negative vectors carry cryptographically valid signatures on purpose. `001-missing-nonce`, `003-signature-agent-not-covered`, and `006-two-signatures` verify once the targeted check is removed, and `004-key-not-in-named-directory` verifies under a verifier that looks keys up by thumbprint alone. Both governance negatives carry valid signatures too: `002` verifies under a thumbprint-only lookup, and `003` verifies when the governance-agent check is skipped. A verifier that accepts any of them has skipped a profile rule, not failed at cryptography. + +## Running vectors against an implementation + +1. Parse each vector. +2. Build the verifier's directory store from `directories`, keyed by origin, selecting entries from `keys.json`. +3. Preload `test_harness_state` into the verifier, then reset it before the next vector. +4. Build the request from `request`, and invoke verification with `reference_now` as the wall clock. +5. Assert on `expected_outcome`: identities for positive vectors, status for negative vectors. + +Run the positive vectors first. If `positive/001` fails, the signature base, key loading, or thumbprint computation is wrong; the `expected_signature_base` field isolates the first of those. Then run `005` (parse), `001`, `003`, and `006` (profile rules without state), `004` (key lookup), and `002` (replay state). Run the governance vectors last; `003` is the one that shows a token is accepted for its issuer, not for its key. + +## Generating the vectors + +`scripts/generate-wba-profile-vectors.mjs` writes every file in this directory except this README. It derives the keys from their seeds, builds each signature base component by component, and signs it with Ed25519. Run `node scripts/generate-wba-profile-vectors.mjs --check` to confirm the committed files match the generator. The positive values are the ones in RFC #7878's illustrations. Do not hand-edit the vector files; change the generator and run it again. + +`tests/wba-profile-vectors.test.cjs` verifies the vectors without sharing code with the generator. It parses the headers, rebuilds each signature base from the request, and checks signatures and digests with `node:crypto`. It verifies the governance tokens with a minimal compact-JWS verifier, one named check per stage. For each negative vector it asserts the expected status and the check that fails. For each negative vector with a valid signature, it asserts that the request verifies once that one check is skipped. `npm run test:wba-profile-vectors` runs both. The positive vectors were also verified with a generic RFC 9421 library ([`http-message-sig`](https://www.npmjs.com/package/http-message-sig)) and, for the single-signer request, with Cloudflare's [`web-bot-auth`](https://www.npmjs.com/package/web-bot-auth) library. + +## Adding vectors + +Every added vector MUST: + +1. Cite the section of `wba-profile.mdx` it grades in `spec_reference`. +2. Use only keys from `keys.json`, and list every directory the verifier may consult in `directories`. +3. State `expected_outcome.status` for a negative vector in the profile's status-code vocabulary, and `accept_signature` for a `403`. +4. Include `expected_signature_base` for every label when the signature headers parse, computed from the request as sent. +5. Include `test_harness_state` when the vector needs preloaded verifier state. +6. Come from `scripts/generate-wba-profile-vectors.mjs`. A negative vector also needs an entry in `TARGETS` (or `GOVERNANCE_TARGETS` for a governance token) in `tests/wba-profile-vectors.test.cjs`, naming the check it targets. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json new file mode 100644 index 000000000..141e1afa1 --- /dev/null +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -0,0 +1,25 @@ +{ + "repository": "adcontextprotocol/adcp", + "pull_request": 7894, + "commit": "e24eeb090c4365707641099cbbb45f9945c6d6e0", + "path": "static/compliance/source/test-vectors/wba-profile/", + "vendored_on": "2026-10-16", + "renamed": { + "README.md": "UPSTREAM-README.md" + }, + "sha256": { + "governance/001-governance-token.json": "8e3a53bb9fa52b376d242ba67e0a86246af2fb97b44a9407212f65e506ed213b", + "governance/002-governance-token-key-not-in-issuer-directory.json": "8559d3aab6a1db4f2e8afe6d81f36a6e2e54b262464d6179ee04d6846ec9d557", + "governance/003-governance-token-from-non-governance-agent.json": "cbf328b7b7fe63df938da84ab86a94310f3ea9c232da99323cc32fbcee29e65b", + "keys.json": "08637bc288547315e6ce72e895d18ff5f3ab20a5f24375a15d0ebda43a993b3c", + "negative/001-missing-nonce.json": "12a60d6067c347976f2ec1769c2078c07f049f490a17c3da602898b4fdc8c3ad", + "negative/002-replayed-nonce.json": "7471d84d3aca75a196439b80c9a605230601c5495464daf6da6d7f9baeed1971", + "negative/003-signature-agent-not-covered.json": "fd2203fe8e445344c4fa27331d119216f6fd8383e8cfd0cb0c85b1dcd29019fc", + "negative/004-key-not-in-named-directory.json": "366094bc9af941dd20127402c8ebf03ab6eff1094dc54363fabec759b75ceb67", + "negative/005-malformed-signature-agent.json": "c0bf6e2d95be5f47d18a26abbcf0c38b2fb8a229a77d4acc4cad76fecad61f59", + "negative/006-two-signatures.json": "6dc88d4db2205ba7f0611fd9d4e2e6d7106ed45ea9c8238c5a801abb660901a1", + "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", + "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", + "UPSTREAM-README.md": "0dca00383bd0907bbf0fadfe09fb9787fd7cdfeabbcdc23b52c17038e1976bfd" + } +} diff --git a/tests/conformance/vectors/wba-profile/governance/001-governance-token.json b/tests/conformance/vectors/wba-profile/governance/001-governance-token.json new file mode 100644 index 000000000..a189b5eee --- /dev/null +++ b/tests/conformance/vectors/wba-profile/governance/001-governance-token.json @@ -0,0 +1,46 @@ +{ + "name": "Governance token issued by the governance identity https://governance-7k3q.com with its own key", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#one-agent-per-purpose", + "signing_profile": "web-bot-auth", + "document_type": "governance_context", + "reference_now": 1790841610, + "jws": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWRjcC1nb3YrandzIiwiamt1IjoiaHR0cHM6Ly9nb3Zlcm5hbmNlLTdrM3EuY29tLy53ZWxsLWtub3duL2h0dHAtbWVzc2FnZS1zaWduYXR1cmVzLWRpcmVjdG9yeSIsImtpZCI6IjFYbEozRUl0ZTdiUE9FcUtRdEtyS05UTnk1aHl3VHlyd0ZYV3JNYlhLcUUifQ.eyJpc3MiOiJodHRwczovL2dvdmVybmFuY2UtN2szcS5jb20iLCJhdWQiOiJodHRwczovL3NlbGxlci03azNxLmNvbS9tY3AvIiwicGhhc2UiOiJpbnRlbnQiLCJqdGkiOiIwMUo5WlE0TThUNlczUjVZMks3TjFQMEI5QyIsImlhdCI6MTc5MDg0MTUwMCwiZXhwIjoxNzkwODQyNDAwfQ.p5jSxfBPrQ5Yj7PAGM8sWvABhdHON-6hKAeoaKmZnGdOslxlGrWmWOmjmk1PCCWinRiZAtJEIkaSoWOTZAs6AA", + "decoded": { + "header": { + "alg": "Ed25519", + "typ": "adcp-gov+jws", + "jku": "https://governance-7k3q.com/.well-known/http-message-signatures-directory", + "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + }, + "payload": { + "iss": "https://governance-7k3q.com", + "aud": "https://seller-7k3q.com/mcp/", + "phase": "intent", + "jti": "01J9ZQ4M8T6W3R5Y2K7N1P0B9C", + "iat": 1790841500, + "exp": 1790842400 + } + }, + "directories": { + "https://governance-7k3q.com": { + "keys_ref": [ + "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + ] + }, + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + } + }, + "role_listing": { + "adcp:governance": [ + "https://governance-7k3q.com" + ] + }, + "expected_outcome": { + "success": true, + "issuer": "https://governance-7k3q.com" + }, + "$comment": "The token in RFC #7878's illustrations. jku is the WBA directory of the iss origin, the kid is in that directory, the signature verifies, and the iss origin is listed in the governance role. role_listing stands in for the record the verifier already trusts: in deployment, the governance-typed agents[] entry in the buyer's brand.json, or the adcp:governance role in trust.json once #7809 lands. The token's other claims (aud, phase, sub, action binding, revocation, replay) follow the existing governance checklist and are not graded here." +} diff --git a/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json b/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json new file mode 100644 index 000000000..b10e2e90a --- /dev/null +++ b/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json @@ -0,0 +1,47 @@ +{ + "name": "Governance token naming the governance agent as issuer, signed with a key that only the buyer agent publishes", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#one-agent-per-purpose", + "signing_profile": "web-bot-auth", + "document_type": "governance_context", + "reference_now": 1790841610, + "jws": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWRjcC1nb3YrandzIiwiamt1IjoiaHR0cHM6Ly9nb3Zlcm5hbmNlLTdrM3EuY29tLy53ZWxsLWtub3duL2h0dHAtbWVzc2FnZS1zaWduYXR1cmVzLWRpcmVjdG9yeSIsImtpZCI6IjZXc0tNQ09iYmE0OVYzdU4wdklFTm5rMUJLQWs3Yzl6akJKY1dLeVE5X0kifQ.eyJpc3MiOiJodHRwczovL2dvdmVybmFuY2UtN2szcS5jb20iLCJhdWQiOiJodHRwczovL3NlbGxlci03azNxLmNvbS9tY3AvIiwicGhhc2UiOiJpbnRlbnQiLCJqdGkiOiIwMUo5WlE0TThUNlczUjVZMks3TjFQMEI5RCIsImlhdCI6MTc5MDg0MTUwMCwiZXhwIjoxNzkwODQyNDAwfQ.c4KwhdVq5e1Avl0r2GvuO0SJVQb2WOa7rxzCP_vpnoBWUe0XqIqgd_wdTLnsK4CKPkBITcSDzj-AlKcFiKNXBA", + "decoded": { + "header": { + "alg": "Ed25519", + "typ": "adcp-gov+jws", + "jku": "https://governance-7k3q.com/.well-known/http-message-signatures-directory", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + }, + "payload": { + "iss": "https://governance-7k3q.com", + "aud": "https://seller-7k3q.com/mcp/", + "phase": "intent", + "jti": "01J9ZQ4M8T6W3R5Y2K7N1P0B9D", + "iat": 1790841500, + "exp": 1790842400 + } + }, + "directories": { + "https://governance-7k3q.com": { + "keys_ref": [ + "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + ] + }, + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + } + }, + "role_listing": { + "adcp:governance": [ + "https://governance-7k3q.com" + ] + }, + "expected_outcome": { + "success": false, + "error_code": "governance_key_unknown", + "reason": "The kid is not in the key directory of the iss origin. Key lookup is keyed on the pair of origin and key, so a key another agent publishes cannot sign as the governance agent." + }, + "$comment": "The signature is valid under the buyer agent's key, which the directories object lists under the buyer agent's origin. A verifier that looks keys up by thumbprint alone finds the key and accepts the token. role_listing stands in for the record the verifier already trusts: in deployment, the governance-typed agents[] entry in the buyer's brand.json, or the adcp:governance role in trust.json once #7809 lands. The token's other claims (aud, phase, sub, action binding, revocation, replay) follow the existing governance checklist and are not graded here." +} diff --git a/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json b/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json new file mode 100644 index 000000000..23e93626c --- /dev/null +++ b/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json @@ -0,0 +1,47 @@ +{ + "name": "Governance token issued by the buyer agent https://buyer-7k3q.com, which is not the buyer's governance agent, with its own key and directory", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#one-agent-per-purpose", + "signing_profile": "web-bot-auth", + "document_type": "governance_context", + "reference_now": 1790841610, + "jws": "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWRjcC1nb3YrandzIiwiamt1IjoiaHR0cHM6Ly9idXllci03azNxLmNvbS8ud2VsbC1rbm93bi9odHRwLW1lc3NhZ2Utc2lnbmF0dXJlcy1kaXJlY3RvcnkiLCJraWQiOiI2V3NLTUNPYmJhNDlWM3VOMHZJRU5uazFCS0FrN2M5empCSmNXS3lROV9JIn0.eyJpc3MiOiJodHRwczovL2J1eWVyLTdrM3EuY29tIiwiYXVkIjoiaHR0cHM6Ly9zZWxsZXItN2szcS5jb20vbWNwLyIsInBoYXNlIjoiaW50ZW50IiwianRpIjoiMDFKOVpRNE04VDZXM1I1WTJLN04xUDBCOUUiLCJpYXQiOjE3OTA4NDE1MDAsImV4cCI6MTc5MDg0MjQwMH0.yrat0BpIuHUsbg-ErWFDHtOpqPYvUcOYgpnbGXPIl5EfsFJMk9FHV2BbY6pdc48lYoLqOtKNyNYgHdvVgbDcCA", + "decoded": { + "header": { + "alg": "Ed25519", + "typ": "adcp-gov+jws", + "jku": "https://buyer-7k3q.com/.well-known/http-message-signatures-directory", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + }, + "payload": { + "iss": "https://buyer-7k3q.com", + "aud": "https://seller-7k3q.com/mcp/", + "phase": "intent", + "jti": "01J9ZQ4M8T6W3R5Y2K7N1P0B9E", + "iat": 1790841500, + "exp": 1790842400 + } + }, + "directories": { + "https://governance-7k3q.com": { + "keys_ref": [ + "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + ] + }, + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + } + }, + "role_listing": { + "adcp:governance": [ + "https://governance-7k3q.com" + ] + }, + "expected_outcome": { + "success": false, + "error_code": "governance_issuer_not_authorized", + "reason": "The iss origin is not the buyer's governance agent. The key, jku, and signature are all consistent, so this is the check that rejects the token." + }, + "$comment": "Every check up to the governance-agent check passes: jku is the iss origin's directory, the kid is in it, and the signature verifies. A token is accepted for who issued it, not for which key signed it. A verifier that checks only that the key sits in the iss directory accepts this token. role_listing stands in for the record the verifier already trusts: in deployment, the governance-typed agents[] entry in the buyer's brand.json, or the adcp:governance role in trust.json once #7809 lands. The token's other claims (aud, phase, sub, action binding, revocation, replay) follow the existing governance checklist and are not graded here." +} diff --git a/tests/conformance/vectors/wba-profile/keys.json b/tests/conformance/vectors/wba-profile/keys.json index 3174c7048..f66c718ab 100644 --- a/tests/conformance/vectors/wba-profile/keys.json +++ b/tests/conformance/vectors/wba-profile/keys.json @@ -21,16 +21,6 @@ "use": "sig", "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" }, - { - "identity": "https://relay.agency-7k3q.com", - "seed_name": "relay-request", - "kty": "OKP", - "crv": "Ed25519", - "kid": "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ", - "x": "8nrvWz557SHSlmuXE2dMstsU_WmRQNze3VIIeNhN-VE", - "use": "sig", - "_private_d_for_test_only": "9HMKspy6Bjt8vLzngwm2L1pzrbxOn6A7nTDBRhmRYOE" - }, { "identity": "https://governance-7k3q.com", "seed_name": "governance-document", diff --git a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json index 9a86106fc..eaeb79b53 100644 --- a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json @@ -36,11 +36,12 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth" }, "reason": "The profile requires an RFC 9421 nonce on every signed request. Without one the verifier cannot detect replay." }, - "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403 with Accept-Signature listing nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403 with Accept-Signature listing nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json index f22908699..c1887bbe1 100644 --- a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json @@ -37,7 +37,7 @@ }, "expected_outcome": { "success": false, - "status": 403, + "status": 429, "accept_signature": { "label": "sig1", "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", @@ -46,6 +46,7 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth", @@ -53,5 +54,5 @@ }, "reason": "The verifier MUST reject a nonce it has already seen for the same identity within the signature's lifetime." }, - "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The 403 carries Accept-Signature with a verifier-chosen nonce value (RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The 429 carries Accept-Signature with a verifier-chosen nonce value (Web Bot Auth section 5.3; RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json index 3417218b6..7465d9db7 100644 --- a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json +++ b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json @@ -36,11 +36,12 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth" }, "reason": "Web Bot Auth section 5.2.1: the Signature-Agent member keyed to the signature label MUST be signed as a component. An uncovered member is an unverified claim and MUST NOT be attributed (section 4.1)." }, - "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json similarity index 91% rename from tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json rename to tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json index 62d51df5d..ec522dfa5 100644 --- a/tests/conformance/vectors/wba-profile/negative/006-key-not-in-named-directory.json +++ b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json @@ -41,11 +41,12 @@ "expires", "nonce", "keyid", + "alg", "tag" ], "tag": "web-bot-auth" }, "reason": "Key lookup is keyed on the (origin, keyid) pair, never on keyid alone (Web Bot Auth section 5.4). The directory at the named origin holds no key with this thumbprint, so the request is unverified and MUST NOT be attributed to that origin (section 6.10)." }, - "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json b/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json deleted file mode 100644 index eebbaa208..000000000 --- a/tests/conformance/vectors/wba-profile/negative/004-relay-omits-inner-signature.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Relay adds a second signature that does not cover the brand agent's signature members", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\");created=1790841601;expires=1790841901;nonce=\"pFCSjj_rt5LZuG-V4YvQAcOlDBrfb8Y35GQsMHTcpO99SLTcz-osapOLlF7767B6CyiDfZCertLo87_dfioQHg\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:2yPlznBMeRW3OuZL0nTEiAre21tcqZH04+dfLsykHH+5FOQzdACO+Bf7i1CYQE24TWrz5NallQc2+yr+ehX1CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\");created=1790841601;expires=1790841901;nonce=\"pFCSjj_rt5LZuG-V4YvQAcOlDBrfb8Y35GQsMHTcpO99SLTcz-osapOLlF7767B6CyiDfZCertLo87_dfioQHg\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "relay", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The profile requires each later signer to cover the signature-agent, signature-input, and signature members of the signature before it. The relay label covers none of the brand label's members." - }, - "$comment": "Both signatures verify on their own, so a verifier that checks only cryptographic validity accepts the request. The relay's signature then records nothing about the brand agent's signature having been present, which is the evidence the profile requires of a relay (Web Bot Auth section 5.2.2 and Appendix D.2). The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json b/tests/conformance/vectors/wba-profile/negative/005-malformed-signature-agent.json similarity index 100% rename from tests/conformance/vectors/wba-profile/negative/007-malformed-signature-agent.json rename to tests/conformance/vectors/wba-profile/negative/005-malformed-signature-agent.json diff --git a/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json b/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json deleted file mode 100644 index 7fa9df29b..000000000 --- a/tests/conformance/vectors/wba-profile/negative/005-relay-inner-signature-altered.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Brand agent's signature altered after the relay signed over it", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:AQHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:ra29HdXx7Hl46Tb9bx+8hhZQ8sL7xeeIC0kaWoUTS7qZyjSd9IyZa7jFcdbjkDFMfnmhHtu6RD+WFZ84Tz78CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"signature-input\";key=\"brand\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"\n\"signature\";key=\"brand\": :AQHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": false, - "status": 403, - "accept_signature": { - "label": "relay", - "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\")", - "parameters": [ - "created", - "expires", - "nonce", - "keyid", - "tag" - ], - "tag": "web-bot-auth" - }, - "reason": "The relay label claims to cover \"signature\";key=\"brand\", but the value on the wire is not the one the relay signed, so the relay's signature fails. The brand label's signature fails as well." - }, - "$comment": "Derived from positive/002-relay-request-two-signatures.json by changing the first character of the brand signature. Every web-bot-auth signature on a request must verify, so one failure rejects the request. The vector shows that the relay's signature binds the brand agent's signature bytes: changing or removing them breaks the relay's signature too. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." -} diff --git a/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json new file mode 100644 index 000000000..dd02f37fe --- /dev/null +++ b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json @@ -0,0 +1,53 @@ +{ + "name": "Two web-bot-auth signatures on one request: positive/001 plus a second valid signature from https://agent.brand-7k3q.com", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#verification", + "signing_profile": "web-bot-auth", + "reference_now": 1790841610, + "request": { + "method": "POST", + "url": "https://seller-7k3q.com/mcp/", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", + "Signature-Agent": "sig1=\"https://buyer-7k3q.com\", sig2=\"https://agent.brand-7k3q.com\"", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\", sig2=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig2\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "Signature": "sig1=:ihPGBC7XQPM6WkIYhYQvFHgwTpbssvuQzkb0FfwkuebREyF32sXhiRxXbXWRZMp0O//C4I2g1a2Kui56ffEfBQ==:, sig2=:UdBx3V5QrRm84M54KvhTG/DxP+i2cNRdPmKgbUVJRvy2omQm6buIJRnOB8Rray4p8mSEy/JuaopoFISc1a2SDQ==:" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" + }, + "directories": { + "https://buyer-7k3q.com": { + "keys_ref": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + }, + "https://agent.brand-7k3q.com": { + "keys_ref": [ + "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" + ] + } + }, + "expected_signature_base": { + "sig1": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig1\": \"https://buyer-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\");created=1790841600;expires=1790841900;nonce=\"Mn-ZM8SnGESx0-rmyYMuTkJFerfbwEii2C0qNkDP8ugpcuv0pcrt6Da2ifr-6MTmfVeZhnFymLeAofeXJbFaxA\";keyid=\"6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I\";alg=\"ed25519\";tag=\"web-bot-auth\"", + "sig2": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"sig2\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig2\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"" + }, + "expected_outcome": { + "success": false, + "status": 403, + "accept_signature": { + "label": "sig1", + "components": "(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"sig1\")", + "parameters": [ + "created", + "expires", + "nonce", + "keyid", + "alg", + "tag" + ], + "tag": "web-bot-auth" + }, + "reason": "The profile takes exactly one web-bot-auth signature and one Signature-Agent member per request (RFC 9421 section 3.2.1). Web Bot Auth section 5.2.2 permits more than one signature; the profile does not accept one." + }, + "$comment": "The sig1 label is byte-identical to positive/001-single-signer-request.json, and the sig2 label is a second valid signature over the same request from another identity. Each verifies on its own against its own directory, so a verifier that loops over every label and accepts when each verifies has skipped the count check. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." +} diff --git a/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json b/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json deleted file mode 100644 index 9ceeb670b..000000000 --- a/tests/conformance/vectors/wba-profile/positive/002-relay-request-two-signatures.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "Relay: brand agent signs, agency relay forwards unchanged and adds a signature covering the brand agent's members", - "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#relays", - "signing_profile": "web-bot-auth", - "reference_now": 1790841610, - "request": { - "method": "POST", - "url": "https://seller-7k3q.com/mcp/", - "headers": { - "Content-Type": "application/json", - "Content-Digest": "sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:", - "Signature-Agent": "brand=\"https://agent.brand-7k3q.com\", relay=\"https://relay.agency-7k3q.com\"", - "Signature-Input": "brand=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\", relay=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "Signature": "brand=:7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:, relay=:ra29HdXx7Hl46Tb9bx+8hhZQ8sL7xeeIC0kaWoUTS7qZyjSd9IyZa7jFcdbjkDFMfnmhHtu6RD+WFZ84Tz78CA==:" - }, - "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" - }, - "directories": { - "https://agent.brand-7k3q.com": { - "keys_ref": [ - "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI" - ] - }, - "https://relay.agency-7k3q.com": { - "keys_ref": [ - "ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ" - ] - } - }, - "expected_signature_base": { - "brand": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"", - "relay": "\"@method\": POST\n\"@target-uri\": https://seller-7k3q.com/mcp/\n\"@authority\": seller-7k3q.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:yeYok+hSZEsVifM6QdAl+jXRfSJ/75y4NS4MXUWaOdU=:\n\"signature-agent\";key=\"relay\": \"https://relay.agency-7k3q.com\"\n\"signature-agent\";key=\"brand\": \"https://agent.brand-7k3q.com\"\n\"signature-input\";key=\"brand\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"brand\");created=1790841600;expires=1790841900;nonce=\"YwSl5nr5sFmghcAAPcAs5F3qh9fJLt9-2hWehzPpaxqlJzZpjJ4xxRKFpVPfuC8fM_Zb1VkZLSHH00cYWL7J-g\";keyid=\"hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI\";alg=\"ed25519\";tag=\"web-bot-auth\"\n\"signature\";key=\"brand\": :7QHcZuQ1NGje/glPbC1DTSmnKDFPf8FIJohfZEOs6/INcF1I5I6xcNsWz3+Czj9mtGQWRG9wmDOWTAmnPQcoAA==:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\" \"signature-agent\";key=\"relay\" \"signature-agent\";key=\"brand\" \"signature-input\";key=\"brand\" \"signature\";key=\"brand\");created=1790841601;expires=1790841901;nonce=\"-GaxnHg03kcsnLVNnfSCtmu1a_zGjiVj6mJkFsZhxKyixpqR8yb_rkbLaG1ZA2sbwwoJjDQuifAeZj1-hBohVw\";keyid=\"ZtaFf5jj-EzorJmZn6mX3YnmK_QyZKVLbbinzIQKOjQ\";alg=\"ed25519\";tag=\"web-bot-auth\"" - }, - "expected_outcome": { - "success": true, - "identities": { - "brand": "https://agent.brand-7k3q.com", - "relay": "https://relay.agency-7k3q.com" - } - }, - "$comment": "Both web-bot-auth signatures must verify, each against its own covered components and its own identity's directory (Web Bot Auth section 5.2.2). The relay label covers the brand label's signature-agent, signature-input, and signature members, as section 5.2.2 requires of a signer that covers another signature and as Appendix D.2 illustrates. Whether the relay may act for the brand is an authorization question answered by the brand's brand.json, outside this profile." -} diff --git a/tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json b/tests/conformance/vectors/wba-profile/positive/002-signed-directory-response.json similarity index 100% rename from tests/conformance/vectors/wba-profile/positive/003-signed-directory-response.json rename to tests/conformance/vectors/wba-profile/positive/002-signed-directory-response.json diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 389b5aa9e..fa0563d57 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -17,8 +17,6 @@ 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 @@ -69,8 +67,7 @@ OriginRevocationChecker, WbaDiscoveryError, WbaVerificationError, - _base, - _sign, + _WbaComponentResolver, sign_key_directory, verify_directory_response, verify_wba_request, @@ -111,10 +108,10 @@ def _signed(private: Any, body: bytes, *, origin: str = ORIGIN, url: str = TARGE return headers -def _member(value: str, label: str = "sig1") -> Any: - parsed = Dictionary() - parsed.parse(value.encode("ascii")) - return parsed[label] +def _params(value: str, label: str = "sig1") -> dict[str, Any]: + from adcp.signing._rfc9421 import parse_dictionary + + return parse_dictionary(value)[label][1] def _recording_app(config: RequestSignatureVerification, *, transport: str = "mcp"): @@ -388,28 +385,23 @@ async def listed(url, *, allow_private=False): assert signer.principal_id == (None if revocation == "no_list" else "principal-1") -async def test_revoked_relay_key_is_refused_like_the_originator(): +async def test_revoked_key_is_refused_without_a_challenge(): 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"), - ) - ) + signer = VerifiedSigner("o" * 43, "ed25519", "sig1", time.time(), agent_url=ORIGIN) class Checker: + def __init__(self, status): + self._status = status + async def status(self, origin, keyid): - return "revoked" if keyid == relay.key_id else "current" + return self._status - assert (await check_wba_revocation(Checker(), (originator,)))[0].revocation == "current" + assert (await check_wba_revocation(Checker("current"), (signer,)))[0].revocation == "current" with pytest.raises(WbaRevokedKeyError) as revoked: - await check_wba_revocation(Checker(), (originator, relay)) + await check_wba_revocation(Checker("revoked"), (signer,)) assert revoked.value.status == 403 - assert revoked.value.label == "sig2" + assert revoked.value.label == "sig1" assert not revoked.value.headers @@ -594,24 +586,24 @@ async def test_grant_domains_compare_as_registrable_domains(): @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), + (b64url_encode(b"\x07" * 16), False), # 22 characters, 128 bits: too short + (b64url_encode(b"\x07" * 32), False), + (b64url_encode(b"\x07" * 63), False), + (b64url_encode(b"\x07" * 65), False), + (b64url_encode(b"\x07" * 64) + "=", False), # padded base64url is not canonical + (b64url_encode(b"\x07" * 64), True), # the profile's spelling + (__import__("base64").b64encode(b"\xfb" * 64).decode(), True), # Cloudflare's ], ) -async def test_verifier_accepts_22_to_256_character_nonces(nonce, accepted): - """64 bytes is RECOMMENDED for signers; a compliant signer may send fewer.""" +async def test_verifier_requires_a_64_byte_nonce(nonce, accepted): + """The profile requires a 64-byte nonce on every signed request (adcp#7894).""" private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) body = _tool("get_products") if not accepted: - with pytest.raises(ValueError, match="128 bits"): + with pytest.raises(ValueError, match="64 bytes"): _signed(private, body, created=1000, nonce=nonce) - valid = "B" * 86 + valid = b64url_encode(b"\x05" * 64) headers = _signed(private, body, created=1000, nonce=valid) headers["Signature-Input"] = headers["Signature-Input"].replace(valid, nonce) else: @@ -629,15 +621,17 @@ async def test_verifier_accepts_22_to_256_character_nonces(nonce, accepted): (signer,) = await verify_wba_request(**options) assert signer.agent_url == ORIGIN else: - with pytest.raises(WbaVerificationError, match="nonce"): + with pytest.raises(WbaVerificationError, match="nonce") as caught: await verify_wba_request(**options) + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers 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"]) + assert is_64_byte_nonce(_params(headers["Signature-Input"])["nonce"]) def test_received_target_uri_keeps_percent_encoding_and_query(): @@ -673,49 +667,54 @@ def _unsigned_directory(*jwks: dict[str, Any], media_type: str = "application/js return DirectoryResponse(body, {"Content-Type": media_type}) -def test_plain_jwks_directory_is_accepted_by_thumbprint_unless_signatures_are_required(): +def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): + """Signed directories are required (adcp#7894, WBA Appendix B.1).""" 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 - ) + for media_type in ("application/json", "application/jwk-set+json", DIRECTORY_MEDIA_TYPE): + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response( + ORIGIN, _unsigned_directory(without_kid, media_type=media_type), now=1000 + ) + # A signed directory finds the key by its thumbprint; a kid is optional. + signed = sign_key_directory(agent_origin=ORIGIN, private_keys=[private], created=1000) + body = json.loads(signed.body) + body["keys"] = [without_kid, {"kty": "oct", "k": "c2VjcmV0"}] + raw = json.dumps(body).encode() 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 + ORIGIN, + DirectoryResponse( + raw, {**signed.headers, "Content-Digest": compute_content_digest_sha256(raw)} + ), + now=1001, ) def _partly_signed_directory(signed_key, unsigned_key, *, components=None, created=1000): + from adcp.signing import _rfc9421 + 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", - } + signature_input, signature = _rfc9421.sign( + _rfc9421.Response(200, _rfc9421.Request("GET", ORIGIN + DIRECTORY_PATH), dict(headers)), + alg="ed25519", + private_key=signed_key, + key_id=jwks[0]["kid"], + label="key1", + created=created, + expires=created + 3600, + nonce=None, + tag="http-message-signatures-directory", + covered_component_ids=components or ['"@authority";req', '"content-digest"'], + resolver=_WbaComponentResolver, ) - 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))})) + headers.update({"Signature-Input": signature_input, "Signature": signature}) return DirectoryResponse(body, headers), jwks @@ -728,24 +727,17 @@ def test_signed_directory_indexes_only_keys_with_a_verified_signature(): 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]): + for components in ( + ['"@authority"', '"content-digest"'], + ['"@authority"'], + ['"@authority";req', '"content-digest";key="sha-256"'], + ): 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(): @@ -765,9 +757,9 @@ def test_directory_created_is_backdated_and_tolerates_a_verifier_behind(): 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 + params = _params(response.headers["Signature-Input"], "key1") + assert params["created"] <= before - DIRECTORY_SKEW_ALLOWANCE_SECONDS + 1 + assert params["expires"] >= before + 86400 assert verify_directory_response(ORIGIN, response, now=time.time() - 30) @@ -1435,7 +1427,10 @@ def test_wba_without_authorities_warns_at_boot(caplog): assert "authorities" in caplog.text -async def test_replayed_wba_nonce_is_429_without_a_challenge(): +async def test_replayed_wba_nonce_is_429_with_a_fresh_nonce(): + """A reused nonce is 429 with Accept-Signature carrying a fresh nonce (adcp#7894).""" + from adcp.signing.wba import is_64_byte_nonce + private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) body = _tool("get_products") @@ -1453,7 +1448,11 @@ async def test_replayed_wba_nonce_is_429_without_a_challenge(): with pytest.raises(WbaVerificationError, match="replayed") as replayed: await verify_wba_request(**options) assert replayed.value.status == 429 - assert not replayed.value.headers + challenge = _params(replayed.value.headers["Accept-Signature"]) + sent = _params(headers["Signature-Input"])["nonce"] + assert is_64_byte_nonce(challenge["nonce"]) and challenge["nonce"] != sent + # The fresh nonce is stable for this answer. + assert replayed.value.headers == replayed.value.headers def test_rfc9421_deprecation_warns_only_once_33_is_advertised(monkeypatch): From e81d80090799ebde187551883712fd1eb094dde5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 17 Oct 2026 00:55:23 +0000 Subject: [PATCH 10/46] fix(signing): address review of the RFC 9421 library migration - Read one header spelling everywhere: the library request takes the first spelling of a name, as the profile lookups do, and the step-1 precheck refuses a signed field that a mapping carries under two spellings. - Refuse parameters on a Signature member and a req parameter that is not a true flag. - verify_wba_governance_issuer registers the action-binding crit markers, checks marker/claim pairing, and checks the issuer before key discovery. - Typed duplicate-field errors, an explicit window guard on the shared verify helper, a tab-tolerant profile-selection walk, and contract tests for the http-sf primitives it uses. Refs #1511 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/agent-identity-3-3-migration.md | 3 +- src/adcp/governance.py | 4 +- src/adcp/signing/_header_precheck.py | 20 ++- src/adcp/signing/_rfc9421.py | 129 ++++++++++-------- src/adcp/signing/autosign.py | 4 +- src/adcp/signing/canonical.py | 4 +- src/adcp/signing/crypto.py | 6 +- src/adcp/signing/signer.py | 4 +- src/adcp/signing/verifier.py | 1 + src/adcp/signing/wba.py | 56 ++++++-- .../signing/test_rfc9421_library_contract.py | 63 +++++++++ .../test_structured_field_duplicates.py | 114 ++++++++++++++++ tests/conformance/signing/test_wba_profile.py | 77 +++++++++++ 13 files changed, 404 insertions(+), 81 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index b92c1a4c3..2ec8e2760 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -99,7 +99,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. - **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@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). @@ -277,6 +277,5 @@ From the earlier alignment: ## Not covered yet - 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. -- Accepting the legacy sf-string `Signature-Agent` (a #1511 follow-up). - 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/src/adcp/governance.py b/src/adcp/governance.py index 1cb417c6a..d57e620de 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -1089,14 +1089,14 @@ def issue_governance_authorization( try: key = _jose_key(alg, private_key) # Sorted header members and the canonical claims bytes keep the token - # byte-identical to the profile's compact JSON; joserfc signs it. + # as before for an ASCII kid (joserfc escapes non-ASCII in the header). return jws.serialize_compact( dict(sorted(header.items())), _compact_json(claims), key, registry=_governance_jws_registry(alg), ) - except JoseError as exc: + except (JoseError, TypeError, ValueError) as exc: raise ValueError(f"governance signing key cannot sign {alg}: {exc}") from exc diff --git a/src/adcp/signing/_header_precheck.py b/src/adcp/signing/_header_precheck.py index ac45227ca..66a98ef7a 100644 --- a/src/adcp/signing/_header_precheck.py +++ b/src/adcp/signing/_header_precheck.py @@ -34,7 +34,7 @@ from collections.abc import Mapping, Sequence -from adcp.signing._rfc9421 import parse_dictionary, translate_legacy_binary +from adcp.signing._rfc9421 import DuplicateFieldError, parse_dictionary, translate_legacy_binary from adcp.signing.canonical import ( TargetUriMalformedError, _split_or_reject, @@ -88,6 +88,16 @@ def strict_header_precheck( """ pairs = _header_pairs(headers=headers, raw_headers=raw_headers) + names = [name for name, _ in _header_pairs(headers=headers, raw_headers=None)] + for name in _SINGLE_LINE_SIGNED_HEADERS | {"signature-agent"}: + if names.count(name) > 1: + # A mapping can hold two spellings of one name (``Content-Digest`` + # and ``content-digest``); which one a reader takes is arbitrary. + return ( + f"{name!r} appears under more than one spelling; a repeated signed " + "field is ambiguous and MUST be rejected rather than folded" + ) + if raw_headers is not None: seen: set[str] = set() for name, _ in pairs: @@ -175,15 +185,15 @@ def _dictionary_reason(value: str, header_name: str) -> str | None: try: try: parse_dictionary(value) - except ValueError as exc: - if "duplicate" in str(exc): - raise + except DuplicateFieldError: + raise + except ValueError: # The AdCP 3.0/3.1 Base64URL byte-sequence spelling is the # decoder's call (3.2 refuses it there, still at step 1), not an # ambiguity: judge the field with those tokens translated. parse_dictionary(translate_legacy_binary(value)) except ValueError as exc: - if "duplicate" in str(exc): + if isinstance(exc, DuplicateFieldError): return ( f"{header_name} carries a {exc}; RFC 8941 §3.2 requires rejecting rather " "than resolving, since resolving lets a repeated key downgrade what was signed" diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index 9368c4ecb..66576e00a 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -80,9 +80,13 @@ _PAREN_OPEN, _PAREN_CLOSE = ord("("), ord(")") +class DuplicateFieldError(ValueError): + """A Structured Field repeats a dictionary key or a parameter.""" + + def _refuse_duplicate(key: str, context: str) -> None: where = "dictionary member" if context == "dictionary" else "parameter" - raise ValueError(f"duplicate Structured Field {where} {key!r}") + raise DuplicateFieldError(f"duplicate Structured Field {where} {key!r}") def parse_dictionary(value: str) -> dict[str, Any]: @@ -138,7 +142,7 @@ def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: # Non-ASCII cannot be RFC 8941; "?" stands in for it so the walk stops there. state = ParserState(value.encode("ascii", "replace")) try: - discard_ows(state) + discard_http_ows(state) while state.has_data(): parse_key(state) if state.has_data() and state.data[state.cursor] == _EQUALS: @@ -184,28 +188,22 @@ def dictionary_has_parameter(value: str, name: str, expected: str) -> bool: _LEGACY_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=-_") -def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: - """Every ``:...:`` byte-sequence token outside sf-strings, with its member key. +def _binary_spans(value: str) -> list[tuple[int, int, str | None]]: + """``(start, end, owner)`` of every ``:...:`` token outside sf-strings. - AdCP 3.0 and 3.1 emitted ``Signature`` and ``Content-Digest`` values in - unpadded Base64URL. No RFC 8941 parser accepts that alphabet (section - 3.3.5 is standard Base64), so the legacy profiles translate those tokens - before parsing. This walker only finds the tokens; the translated field - is then parsed strictly by http-sf. The key is the top-level dictionary - key the token is the value of, or ``None`` for a token in a parameter. + ``value[start:end]`` is the token between its colons. ``owner`` is the + top-level dictionary key the token is the value of, or ``None`` for a + token in a parameter. """ - tokens: list[tuple[str | None, str]] = [] - index, length = 0, len(value) - key_start = 0 + spans: list[tuple[int, int, str | None]] = [] + index, length, key_start = 0, len(value), 0 while index < length: char = value[index] if char == '"': index += 1 while index < length and value[index] != '"': index += 2 if value[index] == "\\" else 1 - index += 1 - continue - if char == ":": + elif char == ":": end = value.find(":", index + 1) if end < 0: break @@ -213,52 +211,47 @@ def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: if index > 0 and value[index - 1] == "=": candidate = value[key_start : index - 1].strip() owner = candidate if candidate and ";" not in candidate else None - tokens.append((owner, value[index + 1 : end])) - index = end + 1 - continue - if char == ",": + spans.append((index + 1, end, owner)) + index = end + elif char == ",": key_start = index + 1 index += 1 - return tokens + return spans + + +def legacy_binary_tokens(value: str) -> list[tuple[str | None, str]]: + """Every byte-sequence token outside sf-strings, with the member key it belongs to. + + AdCP 3.0 and 3.1 emitted ``Signature`` and ``Content-Digest`` values in + unpadded Base64URL. No RFC 8941 parser accepts that alphabet (section + 3.3.5 is standard Base64), so the legacy profiles look at, and translate, + those tokens before the strict http-sf parse. + """ + return [(owner, value[start:end]) for start, end, owner in _binary_spans(value)] def translate_legacy_binary(value: str) -> str: """Rewrite Base64URL ``:...:`` tokens as RFC 8941 standard Base64. - A token in standard Base64 is left alone, so a field already in RFC 8941 - form parses unchanged. Only the legacy profiles call this. + Only a token made of Base64 characters that decodes is rewritten; a token + already in standard Base64 is left alone, so a field in RFC 8941 form + parses unchanged, and anything else is left for the strict parse to + refuse. Only the legacy profiles call this. """ - out: list[str] = [] - index, length = 0, len(value) - while index < length: - char = value[index] - if char == '"': - start = index - index += 1 - while index < length and value[index] != '"': - index += 2 if value[index] == "\\" else 1 - index += 1 - out.append(value[start:index]) - continue - if char == ":": - end = value.find(":", index + 1) - if end < 0: - out.append(value[index:]) - break - token = value[index + 1 : end] - if set(token) <= _LEGACY_ALPHABET and (_BASE64URL_ONLY & set(token) or len(token) % 4): - standard = token.replace("-", "+").replace("_", "/").rstrip("=") - try: - raw = base64.b64decode(standard + "=" * (-len(standard) % 4), validate=True) - token = base64.b64encode(raw).decode("ascii") - except (binascii.Error, ValueError): - # Not Base64 in either alphabet: the strict parse refuses it. - pass - out.append(f":{token}:") - index = end + 1 - continue - out.append(char) - index += 1 + out, cursor = [], 0 + for start, end, _owner in _binary_spans(value): + token = value[start:end] + if set(token) <= _LEGACY_ALPHABET and (_BASE64URL_ONLY & set(token) or len(token) % 4): + standard = token.replace("-", "+").replace("_", "/").rstrip("=") + try: + raw = base64.b64decode(standard + "=" * (-len(standard) % 4), validate=True) + token = base64.b64encode(raw).decode("ascii") + except (binascii.Error, ValueError): + # Not Base64 in either alphabet: the strict parse refuses it. + pass + out.append(value[cursor:start] + token) + cursor = end + out.append(value[cursor:]) return "".join(out) @@ -286,7 +279,20 @@ class Request: headers: Mapping[str, str] = field(default_factory=dict) def __post_init__(self) -> None: - self.headers = CaseInsensitiveDict(dict(self.headers)) + self.headers = _first_wins(self.headers) + + +def _first_wins(headers: Mapping[str, str]) -> Any: + """Case-insensitive headers where the first spelling of a name wins. + + The same rule as ``canonical._lookup``, which the profiles use for their + own checks, so the library and the profile never read two different + values for one name (``Content-Digest`` beside ``content-digest``). + """ + folded: dict[str, str] = {} + for name, value in headers.items(): + folded.setdefault(name.lower(), value) + return CaseInsensitiveDict(folded) @dataclass @@ -298,7 +304,7 @@ class Response: headers: Mapping[str, str] = field(default_factory=dict) def __post_init__(self) -> None: - self.headers = CaseInsensitiveDict(dict(self.headers)) + self.headers = _first_wins(self.headers) @property def url(self) -> str: @@ -345,7 +351,9 @@ def resolve(self, component_node: Any) -> str: raise ComponentError("a covered component name must be an sf-string", status=400) if name != name.lower(): raise ComponentError("covered component names must be lowercase") - if params.pop("req", False) is True: + if "req" in params: + if params.pop("req") is not True: + raise ComponentError("the req parameter is a boolean flag") if not isinstance(self.message, Response): raise ComponentError("the req parameter only applies to a response") request_resolver = type(self)(self.message.request) @@ -588,6 +596,7 @@ def verify( alg: str, public_key: Any, resolver: type[ComponentResolver], + window_checked: bool, parameters: Sequence[str] = (), ) -> Mapping[str, str]: """Verify ``label`` with ``HTTPMessageVerifier``; return the covered components. @@ -597,7 +606,12 @@ def verify( the library confirms the signature names the same ``alg``. Raises :class:`SignatureBaseError` when a component does not resolve and :class:`SignatureMismatchError` when the signature does not verify. + + ``window_checked`` must be true: :class:`ProfileVerifier` does not check + ``created``/``expires``, so the caller states it already has. """ + if window_checked is not True: + raise ValueError("check the signature window before verifying") verifier = ProfileVerifier( signature_algorithm=ALGORITHMS[alg], key_resolver=_Key(public_key=public_key), @@ -631,6 +645,7 @@ def verify( "AdcpComponentResolver", "ComponentError", "ComponentResolver", + "DuplicateFieldError", "ProfileSigner", "ProfileVerifier", "Request", diff --git a/src/adcp/signing/autosign.py b/src/adcp/signing/autosign.py index 890038869..57a611694 100644 --- a/src/adcp/signing/autosign.py +++ b/src/adcp/signing/autosign.py @@ -138,8 +138,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "agent_origin", _https_origin(self.agent_origin)) if not isinstance(self.private_key, ed25519.Ed25519PrivateKey): raise ValueError( - "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " - "RFC 9421 profile algorithm only" + "Web Bot Auth here takes an Ed25519 key (sign RSA keys with " + "sign_wba_request); ES256 is an AdCP RFC 9421 profile algorithm only" ) 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") diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index b8aad5b09..0d6a22ecd 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -129,7 +129,9 @@ def build_signature_base( ``http-message-signatures`` builds the base: lines joined with a single LF, no trailing newline, components in ``Signature-Input`` order, then ``@signature-params``. :class:`AdcpComponentResolver` supplies the AdCP - ``@target-uri`` and ``@authority`` canonicalization. + ``@target-uri`` and ``@authority`` canonicalization. The base comes from + ``parsed.components`` and ``parsed.params`` (in order); ``raw_value`` is + not consulted, so ``@signature-params`` is the RFC 8941 serialization. """ from adcp.signing._rfc9421 import AdcpComponentResolver, Request, build_base diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index e08d0669e..b1ddd2ebd 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -223,9 +223,13 @@ def extract_signature_bytes( members = parse_dictionary(translate_legacy_binary(signature_header)) if label not in members: raise KeyError(f"label not found in Signature header: {label}") - value, _params = members[label] + value, params = members[label] if not isinstance(value, bytes): raise ValueError(f"Signature value for {label!r} is not an sf-binary") + if params: + # RFC 9421 §4.2 defines no Signature member parameters; unsigned + # extras are refused rather than ignored. + raise ValueError(f"Signature value for {label!r} carries parameters") return value diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index 47c6954cc..e3b0f09b9 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -219,8 +219,8 @@ def sign_request( if not isinstance(private_key, ed25519.Ed25519PrivateKey): raise ValueError( - "Web Bot Auth signs with ed25519 or rsa-pss-sha512; ES256 is an AdCP " - "RFC 9421 profile algorithm only" + "Web Bot Auth here takes an Ed25519 key (sign RSA keys with " + "sign_wba_request); ES256 is an AdCP RFC 9421 profile algorithm only" ) if key_id != public_directory_key(private_key)["kid"]: raise ValueError("Web Bot Auth key_id must be the public key's RFC 7638 thumbprint") diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 272ad254d..ab4089d11 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -466,6 +466,7 @@ def verify_request_signature( alg=alg, public_key=public_key, resolver=_rfc9421.AdcpComponentResolver, + window_checked=True, # step 5 parameters=tuple(parsed.params), ) except _rfc9421.SignatureBaseError as exc: diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 205adf1f0..a0cfd10f3 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -496,8 +496,8 @@ def _verify_crypto( declared = 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, tuple) or not isinstance(signature[0], bytes): - raise WbaVerificationError("signature must be sf-binary", status=400) + if not isinstance(signature, tuple) or not isinstance(signature[0], bytes) or signature[1]: + raise WbaVerificationError("signature must be a bare sf-binary", status=400) try: public_key = _public_key(alg, jwk) except (ValueError, KeyError, TypeError) as exc: @@ -510,6 +510,7 @@ def _verify_crypto( alg=alg, public_key=public_key, resolver=_WbaComponentResolver, + window_checked=True, # every caller ran _window first parameters=tuple(params), ) except _rfc9421.SignatureBaseError as exc: @@ -1267,7 +1268,9 @@ def __init__(self, code: str, reason: str) -> None: def _governance_registry(alg: str) -> Any: from joserfc.jws import JWSRegistry + from joserfc.registry import HeaderParameter, is_bool + from adcp.governance import GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS from adcp.signing.jws import _AdcpJwsRegistry, _Ed25519EdDSA class _Registry(_AdcpJwsRegistry): @@ -1277,7 +1280,15 @@ class _Registry(_AdcpJwsRegistry): "PS512": JWSRegistry.algorithms["PS512"], } - return _Registry(algorithms=[alg], strict_check_header=False) + # The action-binding claim markers are the only crit extensions. + return _Registry( + header_registry={ + name: HeaderParameter(f"AdCP critical claim marker: {name}", is_bool) + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS + }, + algorithms=[alg], + strict_check_header=False, + ) async def verify_wba_governance_issuer( @@ -1337,12 +1348,21 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_invalid", "governance token alg not allowed") if header.get("typ") != WBA_GOVERNANCE_TYP: raise fail("governance_token_invalid", "governance token typ mismatch") + _check_critical_markers(header, claims, fail) try: issuer = _https_origin(str(claims.get("iss", ""))) except ValueError as exc: raise fail("governance_token_invalid", "governance token iss is not an origin") from exc if issuer != claims.get("iss") or header.get("jku") != issuer + DIRECTORY_PATH: raise fail("governance_key_unknown", "jku is not the iss origin's key directory") + try: + authorized = {_https_origin(origin) for origin in governance_agents} + except ValueError as exc: + raise ValueError("governance_agents must be HTTPS origins") from exc + # Checked before key discovery, so a token cannot make the verifier fetch + # a directory from an origin that is not the buyer's governance agent. + if issuer not in authorized: + raise fail("governance_issuer_not_authorized", "iss is not the buyer's governance agent") kid = header.get("kid") if not isinstance(kid, str) or not kid: raise fail("governance_key_unknown", "governance token kid is missing") @@ -1380,15 +1400,33 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_expired", "governance token expired") if issued > now + clock_skew_seconds: raise fail("governance_token_not_yet_valid", "governance token issued in the future") - try: - authorized = {_https_origin(origin) for origin in governance_agents} - except ValueError as exc: - raise ValueError("governance_agents must be HTTPS origins") from exc - if issuer not in authorized: - raise fail("governance_issuer_not_authorized", "iss is not the buyer's governance agent") return issuer +def _check_critical_markers( + header: Mapping[str, Any], claims: Mapping[str, Any], fail: Callable[[str, str], Exception] +) -> None: + """Security.mdx checklist step 4: each action-binding marker, crit entry and claim together.""" + from adcp.governance import GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS + + critical = header.get("crit", []) + if ( + not isinstance(critical, list) + or any(not isinstance(name, str) for name in critical) + or len(set(critical)) != len(critical) + or any(name not in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS for name in critical) + ): + raise fail("governance_token_invalid", "invalid governance JWS crit header") + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: + if (name in claims) != (name in critical and header.get(name) is True): + raise fail("governance_token_invalid", f"{name} claim and critical marker must match") + if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): + raise fail( + "governance_token_invalid", + "authorized_task and authorized_payload_hash must appear together", + ) + + class WbaRevocationStatusChecker(Protocol): """Report a WBA key's state in its origin's signed revocation list.""" diff --git a/tests/conformance/signing/test_rfc9421_library_contract.py b/tests/conformance/signing/test_rfc9421_library_contract.py index cf9425cd3..8c82224e9 100644 --- a/tests/conformance/signing/test_rfc9421_library_contract.py +++ b/tests/conformance/signing/test_rfc9421_library_contract.py @@ -165,3 +165,66 @@ def test_http_sf_reports_every_duplicate(): on_duplicate_key=lambda key, context: calls.append((key, context)), ) assert calls == [("a", "dictionary"), ("p", "parameter"), ("q", "parameter")] + + +def test_http_sf_primitives_the_profile_selection_walk_uses(): + """``dictionary_has_parameter`` walks members with these http-sf functions.""" + from http_sf.bare_item import parse_bare_item + from http_sf.item import parse_item + from http_sf.state import ParserState + from http_sf.util import discard_http_ows, discard_ows, parse_key + + state = ParserState(b' sig1=("a";k=1);tag="x"') + discard_ows(state) + discard_http_ows(state) + assert parse_key(state) == "sig1" + state.cursor += 2 # "=(" + assert parse_item(state) == ("a", {"k": 1}) + state.cursor += 2 # ");" + assert parse_key(state) == "tag" + state.cursor += 1 + assert parse_bare_item(state) == "x" + assert _rfc9421.dictionary_has_parameter( + '\tsig1=("a");tag="web-bot-auth";nonce="x', "tag", "web-bot-auth" + ) + + +def test_req_must_be_a_true_flag(): + response = _rfc9421.Response(200, _rfc9421.Request("GET", "https://example.com/")) + for identifier in ('"@authority";req=?0', '"@authority";req=1'): + with pytest.raises(ValueError, match="boolean flag"): + _rfc9421.build_base( + response, + covered_component_ids=[identifier], + params={}, + resolver=_rfc9421.ComponentResolver, + ) + with pytest.raises(ValueError, match="only applies to a response"): + _rfc9421.build_base( + _rfc9421.Request("GET", "https://example.com/"), + covered_component_ids=['"@authority";req'], + params={}, + resolver=_rfc9421.ComponentResolver, + ) + + +def test_first_spelling_of_a_header_wins_as_in_lookup(): + from adcp.signing.canonical import _lookup + + headers = {"Content-Digest": "first", "content-digest": "second"} + assert _rfc9421.Request("GET", "https://example.com/", headers).headers[ + "content-digest" + ] == _lookup(headers, "content-digest") + + +def test_verify_requires_the_caller_to_have_checked_the_window(): + with pytest.raises(ValueError, match="window"): + _rfc9421.verify( + _rfc9421.Request("GET", "https://example.com/"), + label="sig1", + tag="t", + alg="ed25519", + public_key=None, + resolver=_rfc9421.ComponentResolver, + window_checked=False, + ) diff --git a/tests/conformance/signing/test_structured_field_duplicates.py b/tests/conformance/signing/test_structured_field_duplicates.py index 0b7b40e5d..f6a67887c 100644 --- a/tests/conformance/signing/test_structured_field_duplicates.py +++ b/tests/conformance/signing/test_structured_field_duplicates.py @@ -318,3 +318,117 @@ async def dispatch(scope, receive, send): if not wba: assert response.json()["error"] == REQUEST_SIGNATURE_HEADER_MALFORMED assert not calls and not dispatched and not replay.claims + + +def _signed_32(body=b"{}"): + key = Ed25519PrivateKey.generate() + headers = {"Content-Type": "application/json"} + headers.update( + sign_request( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=body, + private_key=key, + key_id="kid", + alg="ed25519", + created=1000, + signing_profile_version="3.2", + ).as_dict() + ) + return key, headers + + +def _options_32(key, replay=None): + from cryptography.hazmat.primitives import serialization + + from adcp.signing.crypto import b64url_encode + + raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": b64url_encode(raw), + "kid": "kid", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "request-signing", + } + return VerifyOptions( + now=1000, + jwks_resolver=lambda keyid: jwk, + capability=VerifierCapability(covers_content_digest="required"), + operation="create_media_buy", + replay_store=replay or InMemoryReplayStore(), + signing_profile_version="3.2", + ) + + +def test_two_spellings_of_a_signed_header_are_malformed_not_split(): + """A mapping with Content-Digest and content-digest must not let the digest + check and the signature base read different values.""" + import hashlib + + key, headers = _signed_32() + swapped = b'{"amount":999999}' + forged = ( + "sha-256=:" + + __import__("base64").b64encode(hashlib.sha256(swapped).digest()).decode() + + ":" + ) + attack_headers = {"content-digest": forged, **headers} + with pytest.raises(SignatureVerificationError) as rejected: + verify_request_signature( + method="POST", + url="https://seller.com/mcp", + headers=attack_headers, + body=swapped, + options=_options_32(key), + ) + assert rejected.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED + + +async def test_two_spellings_of_a_signed_header_are_400_for_wba(): + key = Ed25519PrivateKey.generate() + headers = {"Content-Type": "application/json"} + headers.update( + sign_wba_request( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=b"{}", + private_key=key, + agent_origin="https://buyer.com", + created=1000, + ) + ) + + async def resolve(keyid, *, signer_origin): + pytest.fail("Ambiguous headers must not reach discovery") + + with pytest.raises(WbaVerificationError) as rejected: + await verify_wba_request( + method="POST", + url="https://seller.com/mcp", + headers={"content-digest": "sha-256=:AAAA:", **headers}, + body=b"{}", + signer_keys=resolve, + replay_store=InMemoryReplayStore(), + now=1000, + ) + assert rejected.value.status == 400 + + +def test_signature_member_parameters_are_malformed(): + key, headers = _signed_32() + headers["Signature"] += ";foo=1" + with pytest.raises(SignatureVerificationError) as rejected: + verify_request_signature( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=b"{}", + options=_options_32(key), + ) + assert rejected.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED + assert rejected.value.step == 1 diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 2b0a5eaae..0dd686208 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -945,3 +945,80 @@ async def dispatch(scope, receive, send): # A current list, or a stale one for a non-spend call, proceeds. assert response.status_code == 200, response.text assert dispatched == [True] + + +def _governance_token(key, header_extra=None, claims_extra=None, *, iss=None): + from joserfc import jws as jose_jws + from joserfc.jwk import OKPKey + + from adcp.signing.wba import _governance_registry + + identity = iss or KEYS["1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE"]["identity"] + header = { + "alg": "EdDSA", + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", + **(header_extra or {}), + } + claims = {"iss": identity, "aud": "https://seller-7k3q.com/mcp/", "iat": 1000, "exp": 1600} + claims.update(claims_extra or {}) + private = private_key_from_jwk( + KEYS["1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE"], d_field="_private_d_for_test_only" + ) + return jose_jws.serialize_compact( + header, + json.dumps(claims).encode(), + OKPKey.import_key(private), + registry=_governance_registry("EdDSA"), + ) + + +@pytest.mark.parametrize("markers", ["matched", "claim_without_marker", "task_without_hash"]) +async def test_governance_action_binding_markers(markers): + """Action-authorizing tokens carry crit markers (security.mdx checklist step 4).""" + from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer + + vector = json.loads((ROOT / "governance/001-governance-token.json").read_text()) + binding = {"authorized_task": "create_media_buy", "authorized_payload_hash": "h"} + header = {"crit": list(binding), **{name: True for name in binding}} + if markers == "claim_without_marker": + header = {} + claims = dict(binding) + if markers == "task_without_hash": + header = {"crit": ["authorized_task"], "authorized_task": True} + claims = {"authorized_task": "create_media_buy"} + token = _governance_token(None, header, claims) + + async def verify(): + return await verify_wba_governance_issuer( + token, + signer_keys=_vector_resolver(vector), + governance_agents=vector["role_listing"]["adcp:governance"], + now=1100, + ) + + if markers == "matched": + assert await verify() == "https://governance-7k3q.com" + return + with pytest.raises(WbaGovernanceError) as caught: + await verify() + assert caught.value.code == "governance_token_invalid" + + +async def test_governance_issuer_is_checked_before_key_discovery(): + from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer + + token = _governance_token(None, iss="https://buyer-7k3q.com") + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("an unlisted issuer must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, + signer_keys=resolve, + governance_agents=["https://governance-7k3q.com"], + now=1100, + ) + assert caught.value.code == "governance_issuer_not_authorized" From 8821f34df6215b4c2874fb34072bc1ab23819054 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 00:16:10 -0400 Subject: [PATCH 11/46] test(signing): grade Web Bot Auth against the Cloudflare reference Add tests/wba_interop/adapter.py, which answers the four operations of the AdCP interop harness (sign_request, sign_directory, verify_request, and verify_directory) through the public adcp.signing API. Add a wba-interop CI job. It checks out tests/wba-interop and the wba-profile vectors from adcontextprotocol/adcp at ADCP_REF, the head of the harness pull request (adcontextprotocol/adcp#8298), installs the harness with npm ci on Node 24, and runs run.mjs against the adapter. --- .github/workflows/ci.yml | 67 +++++++++++++++ scripts/ci/ci_scope.py | 1 + tests/wba_interop/adapter.py | 154 +++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 tests/wba_interop/adapter.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd0883bb4..06616f30f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -366,6 +366,73 @@ jobs: python -m pytest -n0 -p no:cacheprovider -v -ra \ tests/conformance/reporting/test_reporting_row_storage_interop_pg.py + wba-interop: + needs: changes + if: needs.changes.outputs.run_tests == 'true' + name: Web Bot Auth interop with the Cloudflare reference + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + # PURPOSE: the SDK's Web Bot Auth signer and verifier must interoperate with standard WBA. + # The AdCP harness (adcp/tests/wba-interop) grades tests/wba_interop/adapter.py against + # Cloudflare's web-bot-auth, pinned exactly in the harness lockfile, in both directions: + # the reference signs and the SDK verifies, and the SDK signs and the reference verifies. + env: + # adcontextprotocol/adcp commit that carries tests/wba-interop and the wba-profile + # vectors: the head of adcontextprotocol/adcp#8298. Pin its merge commit once it + # merges, and a release tag once one carries the harness. + ADCP_REF: "a7708e41fbcf0da84273a988c519b25a14b41dbc" + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Check out the AdCP harness at ADCP_REF + uses: actions/checkout@v6 + with: + repository: adcontextprotocol/adcp + ref: ${{ env.ADCP_REF }} + path: adcp + filter: blob:none + sparse-checkout: | + tests/wba-interop + static/compliance/source/test-vectors/wba-profile + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Set up uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: "0.12.8" + enable-cache: true + cache-dependency-glob: pyproject.toml + cache-suffix: "3.12" + prune-cache: true + - name: Set up Node 24 + uses: actions/setup-node@v6 + with: + # The harness's package.json requires Node 24. + node-version: "24" + cache: npm + cache-dependency-path: adcp/tests/wba-interop/package-lock.json + - name: Install the harness + run: npm ci --prefix adcp/tests/wba-interop --ignore-scripts + - name: Run the harness against the SDK adapter + run: | + node adcp/tests/wba-interop/run.mjs \ + --adapter "uv run python tests/wba_interop/adapter.py" \ + --vectors adcp/static/compliance/source/test-vectors/wba-profile \ + --junit wba-interop.xml + - name: Preserve the harness report + if: always() + uses: actions/upload-artifact@v7 + with: + name: wba-interop-${{ github.run_attempt }} + retention-days: 7 + path: wba-interop.xml + if-no-files-found: error + pg-conformance-required-gate: name: Postgres conformance tests (Postgres 16) runs-on: ubuntu-latest diff --git a/scripts/ci/ci_scope.py b/scripts/ci/ci_scope.py index 0e07d8f2f..c77d6764f 100644 --- a/scripts/ci/ci_scope.py +++ b/scripts/ci/ci_scope.py @@ -26,6 +26,7 @@ JOB_GROUPS = { "test": "run_tests", "schema-check": "run_tests", + "wba-interop": "run_tests", "pg-conformance": "run_postgres", "pg-reporting-status": "run_postgres", "pg-reporting-materializer": "run_postgres", diff --git a/tests/wba_interop/adapter.py b/tests/wba_interop/adapter.py new file mode 100644 index 000000000..a71b0b72a --- /dev/null +++ b/tests/wba_interop/adapter.py @@ -0,0 +1,154 @@ +"""Web Bot Auth interop adapter for the AdCP harness (``adcp/tests/wba-interop``). + +The harness grades this SDK against Cloudflare's ``web-bot-auth`` reference. It +runs this script once per operation: one JSON operation on standard input, one +JSON result on standard output. Every operation goes through the SDK's public +``adcp.signing`` API, so the harness grades what an adopter ships. + +Operations (``PROTOCOL.md`` in the harness): + +``sign_request`` + ``key`` (private JWK), ``origin``, ``request`` (``method``, ``url``, + ``headers``, ``body``), ``now``. Returns the signed request. +``sign_directory`` + ``keys`` (private JWKs), ``origin``, ``now``. Returns ``status``, + ``headers``, and ``body``. +``verify_request`` + ``request``, ``directories`` (origin to the signed directory response its + well-known path serves), ``now``, and ``prior`` (requests to verify first, + against the same replay store). Returns ``ok``, ``identity``, and + ``keyid``, or a failure. An origin absent from ``directories`` serves + nothing, and nothing is fetched from the network. +``verify_directory`` + ``origin``, ``response``, ``now``. Returns ``ok`` and ``keyids``, or a + failure. + +A failure is ``{"ok": false, "status": ..., "rule": ..., "error": ...}``. The +status is the one the SDK answers with; an error that carries no HTTP status +(the SDK refusing to sign, for example) reports ``0``. + +Run it as ``uv run python tests/wba_interop/adapter.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from joserfc.jwk import JWKRegistry + +from adcp.signing import ( + DirectoryResponse, + DiscoveredSignerKeys, + InMemoryReplayStore, + WbaVerificationError, + sign_key_directory, + sign_wba_request, + verify_directory_response, + verify_wba_request, +) + +DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" + +Result = dict[str, Any] + + +def _private_key(jwk: Mapping[str, Any]) -> Any: + return JWKRegistry.import_key(dict(jwk)).private_key + + +def _failure(exc: Exception) -> Result: + return { + "ok": False, + "status": getattr(exc, "status", 0), + "rule": getattr(exc, "reason", type(exc).__name__), + "error": str(exc), + } + + +def _directory(response: Mapping[str, Any]) -> DirectoryResponse: + return DirectoryResponse( + body=response["body"].encode(), + headers=dict(response["headers"]), + status=response.get("status", 200), + ) + + +async def sign_request(op: Mapping[str, Any]) -> Result: + request = op["request"] + headers = sign_wba_request( + method=request["method"], + url=request["url"], + headers=request["headers"], + body=request.get("body", "").encode(), + private_key=_private_key(op["key"]), + agent_origin=op["origin"], + created=op["now"], + ) + return {**request, "headers": {**request["headers"], **headers}} + + +async def sign_directory(op: Mapping[str, Any]) -> Result: + directory = sign_key_directory( + agent_origin=op["origin"], + private_keys=[_private_key(key) for key in op["keys"]], + created=op["now"] - 60, + ) + return { + "url": op["origin"].rstrip("/") + DIRECTORY_PATH, + "status": directory.status, + "headers": dict(directory.headers), + "body": directory.body.decode(), + } + + +async def verify_request(op: Mapping[str, Any]) -> Result: + served = {origin + DIRECTORY_PATH: response for origin, response in op["directories"].items()} + + async def fetch(url: str, *, allow_private: bool = False) -> DirectoryResponse: + if url not in served: + raise WbaVerificationError(f"no directory at {url}") + return _directory(served[url]) + + now = op["now"] + signer_keys = DiscoveredSignerKeys(fetcher=fetch, clock=lambda: now) + replay_store = InMemoryReplayStore() + for message in [*op.get("prior", []), op["request"]]: + (signer,) = await verify_wba_request( + method=message["method"], + url=message["url"], + headers=message["headers"], + body=message.get("body", "").encode(), + signer_keys=signer_keys, + replay_store=replay_store, + now=now, + ) + return {"ok": True, "identity": signer.agent_url, "keyid": signer.key_id} + + +async def verify_directory(op: Mapping[str, Any]) -> Result: + keys = verify_directory_response(op["origin"], _directory(op["response"]), now=op["now"]) + return {"ok": True, "keyids": sorted(keys)} + + +OPERATIONS: dict[str, Callable[[Mapping[str, Any]], Awaitable[Result]]] = { + "sign_request": sign_request, + "sign_directory": sign_directory, + "verify_request": verify_request, + "verify_directory": verify_directory, +} + + +async def run(op: Mapping[str, Any]) -> Result: + try: + return await OPERATIONS[op["op"]](op) + except Exception as exc: + # The harness grades the refusal itself, so every SDK error is a result. + return _failure(exc) + + +if __name__ == "__main__": + sys.stdout.write(json.dumps(asyncio.run(run(json.loads(sys.stdin.read()))))) From 99ce1a6e8391553484904cad4c27df0e671e1581 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 00:39:20 -0400 Subject: [PATCH 12/46] fix(signing): verify Web Bot Auth as standard WBA does Apply the Web Bot Auth rules that the Cloudflare web-bot-auth reference and the profile state: - Sign and verify with ed25519 only. RSA-PSS leaves the WBA signer, verifier, and directory, and the dead RSA branch of the governance issuer check goes with it. ES256 stays on the AdCP RFC 9421 profile. - Refuse a signature that has no alg parameter. The verifier no longer infers alg from the key. - Ignore a directory key the profile can't use, and the signature that key makes (RFC 7517 section 5). Refuse a directory in which a usable key doesn't sign (WBA Appendix B.1, one signature per key). - Refuse a directory served with any media type other than application/http-message-signatures-directory+json (WBA section 5.5.1). --- docs/agent-identity-3-3-migration.md | 7 +- src/adcp/signing/wba.py | 152 +++++++--------- tests/conformance/signing/test_wba_interop.py | 74 ++------ tests/conformance/signing/test_wba_profile.py | 73 ++++---- tests/test_identity_3_3_alignment.py | 162 ++++++++++++++---- 5 files changed, 245 insertions(+), 223 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 2ec8e2760..8bca68012 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -29,7 +29,7 @@ signing = SigningConfig( 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. -Web Bot Auth signs with `ed25519` or `rsa-pss-sha512`, the profile's two algorithms. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys and RSA keys of 2048 to 8192 bits (signed with `rsa-pss-sha512`, as in the draft's Appendix E vectors). ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 key is refused for Web Bot Auth, and a P-256 key in someone's directory is skipped. +Web Bot Auth signs with `ed25519`, the profile's one algorithm. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys only. ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 or RSA key is refused for Web Bot Auth, and such a key in someone's directory is ignored, with its signature. Every signature names `alg`; a signature without it is refused. Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. The profile requires it signed (WBA Appendix B.1); verifiers refuse an unsigned directory: @@ -102,15 +102,14 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` - **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. - **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@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. ### Key directories `DiscoveredSignerKeys` resolves `(covered origin, thumbprint)`. It never calls capabilities or reads brand.json or trust.json. -- A directory must be signed (WBA Appendix B.1); an unsigned one is a discovery failure. The media type may be `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 (P-256 included) are skipped; private key material fails the directory. -- Every directory signature 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. +- A directory must be signed (WBA Appendix B.1); an unsigned one is a discovery failure. The media type must be `application/http-message-signatures-directory+json` (WBA section 5.5.1); any other media type fails the directory. Keys are found by their RFC 7638 thumbprint; a `kid` is optional and does not affect lookup. Keys the profile cannot use (P-256 and RSA included) are ignored, and so are their signatures (RFC 7517 section 5); private key material fails the directory. +- Every directory signature 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. Every usable key signs (one signature per key, Appendix B.1): a usable key without a verified signature fails the directory. - **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. diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index a0cfd10f3..f17ed7607 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -11,7 +11,7 @@ The profile is adcontextprotocol/adcp#7894 (``wba-profile.mdx``): one ``web-bot-auth`` signature and one ``Signature-Agent`` member per request, -``ed25519`` or ``rsa-pss-sha512``, a 64-byte nonce on every request, and key +``ed25519`` keys only, a 64-byte nonce on every request, and key directories signed per WBA Appendix B.1. RFC 9421 itself comes from ``http-message-signatures`` (signature bases, signing, verification) and ``http-sf`` (Structured Fields); this module keeps the profile rules. @@ -38,7 +38,7 @@ import httpx from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519, rsa +from cryptography.hazmat.primitives.asymmetric import ed25519 from adcp.signing import _rfc9421 from adcp.signing._bounded_http import async_read_limited_bytes @@ -58,10 +58,9 @@ DIRECTORY_PATH = "/.well-known/http-message-signatures-directory" DIRECTORY_MEDIA_TYPE = "application/http-message-signatures-directory+json" ALG_ED25519 = "ed25519" -ALG_RSA_PSS_SHA512 = "rsa-pss-sha512" #: The algorithms the profile allows (``wba-profile.mdx``, Profile rules). #: ES256 is an AdCP RFC 9421 profile algorithm only. -WBA_ALGORITHMS: frozenset[str] = frozenset({ALG_ED25519, ALG_RSA_PSS_SHA512}) +WBA_ALGORITHMS: frozenset[str] = frozenset({ALG_ED25519}) #: Every signed request carries a nonce of exactly 64 random bytes #: (``wba-profile.mdx``, Profile rules; the architecture draft §4.2.2). @@ -73,16 +72,12 @@ #: (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"}) _REQUEST_COMPONENTS = ("@method", "@target-uri", "@authority") _BODY_COMPONENTS = ("content-type", "content-digest") @@ -296,36 +291,24 @@ def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: return b64url_encode(digest) -def _int_member(value: int) -> str: - return b64url_encode(value.to_bytes((value.bit_length() + 7) // 8, "big")) - - -WbaPrivateKey = ed25519.Ed25519PrivateKey | rsa.RSAPrivateKey +WbaPrivateKey = ed25519.Ed25519PrivateKey def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: """Export a WBA public JWK, with a thumbprint kid and no purpose claim. - Ed25519 and RSA (2048 to 8192 bits, used with ``rsa-pss-sha512``) keys - are supported: the profile's two algorithms. + The profile's one algorithm is ``ed25519``, so only Ed25519 keys export. """ + if not isinstance(private_key, ed25519.Ed25519PrivateKey): + raise ValueError("Web Bot Auth signs with Ed25519 keys only") public = private_key.public_key() - jwk: dict[str, Any] - if isinstance(public, ed25519.Ed25519PublicKey): - jwk = { - "kty": "OKP", - "crv": "Ed25519", - "x": b64url_encode( - public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) - ), - } - 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 RSA keys") + jwk: dict[str, Any] = { + "kty": "OKP", + "crv": "Ed25519", + "x": b64url_encode( + public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + ), + } jwk.update({"kid": thumbprint_key_id(jwk), "use": "sig"}) return jwk @@ -339,54 +322,29 @@ def _canonical_b64url(value: object) -> bool: 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. - Only the profile's algorithms qualify: an Ed25519 key signs ``ed25519`` - and an RSA key ``rsa-pss-sha512``. Any other key (P-256 included) is not - a Web Bot Auth key. + The profile's one algorithm qualifies: an Ed25519 key signs ``ed25519``. + Any other key (P-256 and RSA included) is not a Web Bot Auth key. """ shape = (str(jwk.get("kty")), str(jwk.get("crv"))) - alg = {("OKP", "Ed25519"): ALG_ED25519, ("RSA", "None"): ALG_RSA_PSS_SHA512}.get(shape) - if alg is None or jwk.get("use", "sig") != "sig" or _PRIVATE_JWK_MEMBERS & set(jwk): + if ( + shape != ("OKP", "Ed25519") + or jwk.get("use", "sig") != "sig" + or _PRIVATE_JWK_MEMBERS & set(jwk) + ): raise WbaVerificationError("unsupported directory key") # One key, one thumbprint: key members must be canonical base64url. - for member in ("x", "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 = {ALG_ED25519: "EdDSA", ALG_RSA_PSS_SHA512: "PS512"}[alg] - if jwk.get("alg") not in {None, jws_alg}: + if not _canonical_b64url(jwk.get("x")): + raise WbaVerificationError("directory key member is not canonical base64url") + if jwk.get("alg") not in {None, "EdDSA"}: raise WbaVerificationError("directory key algorithm mismatch") if "key_ops" in jwk and ( not isinstance(jwk["key_ops"], list) or "verify" not in jwk["key_ops"] ): raise WbaVerificationError("directory key cannot verify") - return alg - - -def _public_key(alg: str, jwk: Mapping[str, Any]) -> Any: - if alg == ALG_RSA_PSS_SHA512: - return _rsa_public_key(jwk) - return public_key_from_jwk(dict(jwk)) + return ALG_ED25519 def _received_authority(url: str) -> str: @@ -493,13 +451,13 @@ def _verify_crypto( ``alg`` must name the same one. """ alg = _algorithm(jwk) - declared = params.get("alg", alg) - if type(declared) is not str or declared != alg: - raise WbaVerificationError("signature algorithm does not match key") + # Every signature names its algorithm (Profile rules); none is inferred. + if params.get("alg") != alg: + raise WbaVerificationError("signature algorithm is absent or does not match key") if not isinstance(signature, tuple) or not isinstance(signature[0], bytes) or signature[1]: raise WbaVerificationError("signature must be a bare sf-binary", status=400) try: - public_key = _public_key(alg, jwk) + public_key = public_key_from_jwk(dict(jwk)) except (ValueError, KeyError, TypeError) as exc: raise WbaVerificationError("invalid directory key") from exc try: @@ -617,7 +575,8 @@ class _VerifiedDirectory: signatures_expire: float -def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: +def _parse_directory_keys(body: bytes) -> tuple[dict[str, dict[str, Any]], set[str]]: + """Index the usable keys by thumbprint; also return the unusable keys' thumbprints.""" try: document = parse_strict_json(body) except StrictJsonError as exc: @@ -626,6 +585,7 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: 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]] = {} + ignored: set[str] = set() for jwk in keys: if not isinstance(jwk, dict): raise WbaVerificationError("directory entry is not an object") @@ -640,8 +600,13 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: _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. + # A directory may hold keys for other uses: skip what the profile + # cannot verify with (RFC 7517 section 5), and remember it so its + # signature is skipped too. + try: + ignored.add(thumbprint_key_id(jwk)) + except ValueError: + pass # No thumbprint, so no signature can name it. continue if thumbprint in index: raise WbaVerificationError("directory repeats a key") @@ -650,7 +615,7 @@ def _parse_directory_keys(body: bytes) -> dict[str, dict[str, Any]]: index[thumbprint] = {**jwk, "kid": thumbprint} if not index: raise WbaVerificationError("directory holds no usable key") - return index + return index, ignored def _verify_directory( @@ -658,9 +623,10 @@ def _verify_directory( ) -> _VerifiedDirectory: origin = _https_origin(origin) media_type = (_lookup(response.headers, "content-type") or "").split(";", 1)[0] + # WBA section 5.5.1: a directory is served as the directory media type. if ( response.status != 200 - or media_type.strip().lower() not in _DIRECTORY_MEDIA_TYPES + or media_type.strip().lower() != DIRECTORY_MEDIA_TYPE or len(response.body) > _DIRECTORY_MAX_BYTES ): raise WbaVerificationError("invalid key directory response") @@ -673,7 +639,7 @@ def _verify_directory( # 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") - keys = _parse_directory_keys(response.body) + keys, ignored = _parse_directory_keys(response.body) inputs = _dictionary(raw_inputs) signatures = _dictionary(raw_signatures) if set(signatures) != set(inputs): @@ -686,9 +652,14 @@ def _verify_directory( signed: dict[str, dict[str, Any]] = {} earliest = float("inf") for label, member in inputs.items(): - if not _rfc9421.is_inner_list(member) or member[1].get("tag") != DIRECTORY_TAG: + if not _rfc9421.is_inner_list(member): raise WbaVerificationError("invalid directory signature profile") items, params = member + if params.get("keyid") in ignored: + # The signature of a key the profile cannot use is not read. + continue + if params.get("tag") != DIRECTORY_TAG: + raise WbaVerificationError("invalid directory signature profile") _window(params, now=now, directory=True, label=label) covered = {_rfc9421.component_identifier(item) for item in items} # Exact identifiers: a "content-digest" member or a bare @authority @@ -703,8 +674,9 @@ def _verify_directory( ) signed[keyid] = keys[keyid] earliest = min(earliest, float(params["expires"])) - # Only keys carrying a verified possession proof are usable; an unsigned - # key alongside them is ignored. + # One signature per key (Appendix B.1): every usable key proves possession. + if signed.keys() != keys.keys(): + raise WbaVerificationError("a usable directory key did not sign") return _VerifiedDirectory(signed, earliest) @@ -713,11 +685,13 @@ def verify_directory_response( ) -> dict[str, dict[str, Any]]: """Validate a key directory served at ``origin``; return keys by thumbprint. - The response must be signed per WBA Appendix B.1, as the profile - requires: every signature must verify with a key the directory holds, - cover ``"@authority";req`` and ``"content-digest"``, and only the keys - those signatures prove are returned. An unsigned directory is refused. - Every key is indexed by its RFC 7638 thumbprint (a ``kid`` is optional). + The response must be served as :data:`DIRECTORY_MEDIA_TYPE` and signed + per WBA Appendix B.1, as the profile requires: every key the profile can + use signs it, and each of those signatures verifies and covers + ``"@authority";req`` and ``"content-digest"``. A key the profile cannot + use (P-256 or RSA, for example) is ignored, and so is its signature. An + unsigned directory is refused. Every key is indexed by its RFC 7638 + thumbprint (a ``kid`` is optional). """ return _verify_directory(origin, response, now=now).keys @@ -1249,7 +1223,6 @@ async def check_wba_revocation( #: fully specified name for ``EdDSA`` over Ed25519. _GOVERNANCE_JWS_ALGS: dict[str, frozenset[str]] = { ALG_ED25519: frozenset({"EdDSA", "Ed25519"}), - ALG_RSA_PSS_SHA512: frozenset({"PS512"}), } @@ -1324,7 +1297,7 @@ async def verify_wba_governance_issuer( """ from joserfc import jws as jose_jws from joserfc.errors import BadSignatureError, JoseError - from joserfc.jwk import OKPKey, RSAKey + from joserfc.jwk import OKPKey from joserfc.util import urlsafe_b64decode def fail(code: str, reason: str) -> WbaGovernanceError: @@ -1385,9 +1358,7 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_invalid", "governance token alg does not match the key") public = {name: value for name, value in key.jwk.items() if name not in {"kid", "alg"}} try: - jose_key = ( - OKPKey.import_key(public) if key_alg == ALG_ED25519 else RSAKey.import_key(public) - ) + jose_key = OKPKey.import_key(public) jose_jws.deserialize_compact(token, jose_key, registry=_governance_registry(str(alg))) except BadSignatureError as exc: raise fail("governance_token_invalid", "governance token signature invalid") from exc @@ -1436,7 +1407,6 @@ async def status(self, origin: str, keyid: str) -> WbaRevocationStatus: __all__ = [ "ALG_ED25519", - "ALG_RSA_PSS_SHA512", "DEFAULT_DIRECTORY_MAX_STALE_SECONDS", "DIRECTORY_MEDIA_TYPE", "DIRECTORY_SKEW_ALLOWANCE_SECONDS", diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index a02812968..23f7f3eaa 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -3,7 +3,8 @@ 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``. +Appendix E.1.1, signed with RFC 9421's ``test-key-rsa-pss``; the profile signs +with ed25519 only, so it is refused. """ from __future__ import annotations @@ -15,19 +16,17 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from joserfc.jwk import RSAKey from adcp.signing import InMemoryReplayStore from adcp.signing._rfc9421 import Request, parse_dictionary from adcp.signing.wba import ( - ALG_RSA_PSS_SHA512, DirectoryResponse, DiscoveredSignerKeys, WbaVerificationError, - _algorithm, _signature_base, _verify_crypto, is_64_byte_nonce, - public_directory_key, sign_key_directory, sign_wba_request, thumbprint_key_id, @@ -184,23 +183,16 @@ async def fetch(url, *, allow_private=False): ) -def _rsa_jwk(public: rsa.RSAPublicKey) -> dict[str, str]: - from adcp.signing.crypto import b64url_encode +def test_rsa_pss_appendix_e_vector_is_not_a_web_bot_auth_signature(): + """The draft's Appendix E vector: the base matches, and its RSA key is refused. - 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(): + The profile signs with ed25519 only, so an RSA-PSS key never verifies a + Web Bot Auth signature. + """ public = serialization.load_pem_public_key(RFC9421_TEST_KEY_RSA_PSS) assert isinstance(public, rsa.RSAPublicKey) - jwk = _rsa_jwk(public) + jwk = RSAKey.import_key(public).as_dict(private=False) 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" headers = { "signature-agent": 'agent2="https://signature-agent.test"', @@ -216,51 +208,5 @@ def test_rsa_pss_appendix_e_vector(): _components, params = parse_dictionary(APPENDIX_E_INPUT)["sig2"] signature = parse_dictionary(APPENDIX_E_SIGNATURE)["sig2"] message = Request("GET", "https://example.com/", headers) - assert ( + with pytest.raises(WbaVerificationError, match="unsupported directory key"): _verify_crypto(message, label="sig2", params=params, signature=signature, jwk=jwk) - == ALG_RSA_PSS_SHA512 - ) - tampered = Request("GET", "https://example.net/", headers) - with pytest.raises(WbaVerificationError, match="did not verify"): - _verify_crypto(tampered, label="sig2", params=params, signature=signature, jwk=jwk) - - -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 0dd686208..bbfc64c5d 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -29,6 +29,7 @@ SigningConfig, StaticSignerKeys, WbaVerificationError, + _rfc9421, canonical_origin, public_directory_key, sign_key_directory, @@ -628,43 +629,53 @@ async def fetch(url, *, allow_private=False): await resolver(jwk["kid"], signer_origin="https://legacy.com") -async def test_es256_is_not_a_web_bot_auth_algorithm(): - """The profile allows ed25519 and rsa-pss-sha512; ES256 stays on RFC 9421.""" - from cryptography.hazmat.primitives.asymmetric import ec +async def test_signature_without_alg_is_refused(monkeypatch): + """Every signature names ``alg``, and it must match the key (Profile rules).""" + sign = _rfc9421.ProfileSigner.sign - from adcp.signing.crypto import b64url_encode + def sign_without_alg(self, message, **kwargs): + return sign(self, message, **kwargs, include_alg=False) - ed_key, ec_key = ed25519.Ed25519PrivateKey.generate(), ec.generate_private_key(ec.SECP256R1()) - with pytest.raises(ValueError, match="Ed25519 and RSA"): - sign_key_directory(agent_origin="https://buyer.com", private_keys=[ec_key], created=1000) - with pytest.raises(ValueError, match="Ed25519 and RSA"): - sign_wba_request( + key = ed25519.Ed25519PrivateKey.generate() + signed_directory = sign_key_directory( + agent_origin="https://buyer.com", private_keys=[key], created=1000 + ) + headers = {"Content-Type": "application/json"} + with monkeypatch.context() as patched: + patched.setattr(_rfc9421.ProfileSigner, "sign", sign_without_alg) + headers.update( + sign_wba_request( + method="POST", + url="https://seller.com/mcp", + headers=headers, + body=b"{}", + private_key=key, + agent_origin="https://buyer.com", + created=1000, + ) + ) + directory = sign_key_directory( + agent_origin="https://buyer.com", private_keys=[key], created=1000 + ) + assert "alg=" not in headers["Signature-Input"] + assert "alg=" not in directory.headers["Signature-Input"] + with pytest.raises(WbaVerificationError, match="algorithm"): + verify_directory_response("https://buyer.com", directory, now=1001) + + async def fetch(url, *, allow_private=False): + return signed_directory + + with pytest.raises(WbaVerificationError, match="algorithm") as refused: + await verify_wba_request( method="POST", url="https://seller.com/mcp", - headers={"Content-Type": "application/json"}, + headers=headers, body=b"{}", - private_key=ec_key, - agent_origin="https://buyer.com", - created=1000, + signer_keys=DiscoveredSignerKeys(fetcher=fetch, clock=lambda: 1001), + replay_store=InMemoryReplayStore(), + now=1001, ) - # A directory may list a P-256 key for another use; it is skipped, and the - # signed Ed25519 key is the only one returned. - response = sign_key_directory( - agent_origin="https://buyer.com", private_keys=[ed_key], created=1000 - ) - numbers = ec_key.public_key().public_numbers() - p256 = { - "kty": "EC", - "crv": "P-256", - "x": b64url_encode(numbers.x.to_bytes(32, "big")), - "y": b64url_encode(numbers.y.to_bytes(32, "big")), - } - with pytest.raises(WbaVerificationError, match="unsupported directory key"): - from adcp.signing.wba import _algorithm - - _algorithm(p256) - keys = verify_directory_response("https://buyer.com", response, now=1001) - assert list(keys) == [public_directory_key(ed_key)["kid"]] + assert refused.value.status == 403 async def test_unsigned_directory_is_refused(): diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index fa0563d57..5210eb653 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -11,12 +11,14 @@ import asyncio import json import time +from datetime import datetime, timezone from typing import Any import httpx import pytest from asgi_lifespan import LifespanManager -from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa +from joserfc.jwk import ECKey, OKPKey, RSAKey from starlette.requests import Request from starlette.responses import JSONResponse @@ -672,8 +674,13 @@ def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): private = ed25519.Ed25519PrivateKey.generate() jwk = public_directory_key(private) without_kid = {k: v for k, v in jwk.items() if k != "kid"} - for media_type in ("application/json", "application/jwk-set+json", DIRECTORY_MEDIA_TYPE): - with pytest.raises(WbaVerificationError, match="not signed"): + with pytest.raises(WbaVerificationError, match="not signed"): + verify_directory_response( + ORIGIN, _unsigned_directory(without_kid, media_type=DIRECTORY_MEDIA_TYPE), now=1000 + ) + # WBA section 5.5.1: no other media type is a directory. + for media_type in ("application/json", "application/jwk-set+json"): + with pytest.raises(WbaVerificationError, match="invalid key directory response"): verify_directory_response( ORIGIN, _unsigned_directory(without_kid, media_type=media_type), now=1000 ) @@ -692,51 +699,140 @@ def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): ) -def _partly_signed_directory(signed_key, unsigned_key, *, components=None, created=1000): +def _public_jwk(private_key): + key_type = { + ed25519.Ed25519PrivateKey: OKPKey, + ec.EllipticCurvePrivateKey: ECKey, + rsa.RSAPrivateKey: RSAKey, + } + imported = next(kind for base, kind in key_type.items() if isinstance(private_key, base)) + key = imported.import_key(private_key) + return {**key.as_dict(private=False), "kid": key.thumbprint()} + + +def _directory_signed_by( + signers, + *, + unsigned=(), + media_type=DIRECTORY_MEDIA_TYPE, + components=('"@authority";req', '"content-digest"'), +): + """A directory at ``ORIGIN`` that each of ``signers`` signs (Appendix B.1). + + The library signs it directly, so a key the SDK does not sign with still + signs here. ``unsigned`` keys are listed without a signature. + """ + from http_message_signatures import HTTPMessageSigner + from http_message_signatures.algorithms import ECDSA_P256_SHA256, ED25519, RSA_PSS_SHA512 + from adcp.signing import _rfc9421 - 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), - } - signature_input, signature = _rfc9421.sign( - _rfc9421.Response(200, _rfc9421.Request("GET", ORIGIN + DIRECTORY_PATH), dict(headers)), - alg="ed25519", - private_key=signed_key, - key_id=jwks[0]["kid"], - label="key1", - created=created, - expires=created + 3600, - nonce=None, - tag="http-message-signatures-directory", - covered_component_ids=components or ['"@authority";req', '"content-digest"'], - resolver=_WbaComponentResolver, + keys = [_public_jwk(key) for key in signers] + body = json.dumps({"keys": keys + [_public_jwk(key) for key in unsigned]}).encode() + message = _rfc9421.Response( + status_code=200, + request=_rfc9421.Request("GET", ORIGIN + DIRECTORY_PATH), + headers={"Content-Type": media_type, "Content-Digest": compute_content_digest_sha256(body)}, ) - headers.update({"Signature-Input": signature_input, "Signature": signature}) - return DirectoryResponse(body, headers), jwks + algorithms = {"OKP": ED25519, "EC": ECDSA_P256_SHA256, "RSA": RSA_PSS_SHA512} + for number, (private_key, jwk) in enumerate(zip(signers, keys), 1): + HTTPMessageSigner( + signature_algorithm=algorithms[jwk["kty"]], + key_resolver=_rfc9421._Key(private_key=private_key), + component_resolver_class=_WbaComponentResolver, + ).sign( + message, + key_id=jwk["kid"], + created=datetime.fromtimestamp(1000, timezone.utc), + expires=datetime.fromtimestamp(4600, timezone.utc), + label=f"key{number}", + tag="http-message-signatures-directory", + covered_component_ids=list(components), + append_if_signature_exists=True, + ) + return DirectoryResponse(body=body, headers=dict(message.headers)) + + +@pytest.mark.parametrize("kind", ["P-256", "RSA"]) +def test_only_ed25519_signs_web_bot_auth(kind): + """The profile's one algorithm is ed25519 (wba-profile.mdx, Profile rules). + + ES256 stays on the AdCP RFC 9421 profile, and RSA-PSS is not a WBA algorithm. + """ + key = ( + ec.generate_private_key(ec.SECP256R1()) + if kind == "P-256" + else rsa.generate_private_key(public_exponent=65537, key_size=2048) + ) + with pytest.raises(ValueError, match="Ed25519"): + public_directory_key(key) + with pytest.raises(ValueError, match="Ed25519"): + sign_key_directory(agent_origin=ORIGIN, private_keys=[key], created=1000) + with pytest.raises(ValueError, match="Ed25519"): + sign_wba_request( + method="POST", + url="https://seller.com/mcp", + headers={"Content-Type": "application/json"}, + body=b"{}", + private_key=key, + agent_origin=ORIGIN, + created=1000, + ) + # A directory signed only by such a key holds no Web Bot Auth key. + with pytest.raises(WbaVerificationError, match="no usable key"): + verify_directory_response(ORIGIN, _directory_signed_by([key]), now=1001) + + +def test_directory_ignores_unusable_keys_and_their_signatures(): + """An unusable key and the signature it makes are ignored (RFC 7517 section 5). + + A directory shared with other HTTP Message Signatures uses holds keys of + other types, and each of them signs it (Appendix B.1). + """ + ed_key = ed25519.Ed25519PrivateKey.generate() + others = [ + ec.generate_private_key(ec.SECP256R1()), + rsa.generate_private_key(public_exponent=65537, key_size=2048), + ] + for response in ( + _directory_signed_by([ed_key, *others]), + _directory_signed_by([ed_key], unsigned=others), + ): + keys = verify_directory_response(ORIGIN, response, now=1001) + assert list(keys) == [public_directory_key(ed_key)["kid"]] -def test_signed_directory_indexes_only_keys_with_a_verified_signature(): +def test_every_usable_directory_key_signs(): + """A usable key that does not sign refuses the directory: one signature per key.""" 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"]] + response = _directory_signed_by([signed], unsigned=[unsigned]) + with pytest.raises(WbaVerificationError, match="did not sign"): + verify_directory_response(ORIGIN, response, now=1001) + + +@pytest.mark.parametrize("media_type", ["application/json", "application/jwk-set+json"]) +def test_directory_media_type_is_the_wba_one(media_type): + """WBA section 5.5.1: a directory is served as the directory media type.""" + key = ed25519.Ed25519PrivateKey.generate() + accepted = _directory_signed_by([key], media_type=f"{DIRECTORY_MEDIA_TYPE}; charset=utf-8") + assert verify_directory_response(ORIGIN, accepted, now=1001) + with pytest.raises(WbaVerificationError, match="invalid key directory response"): + verify_directory_response( + ORIGIN, _directory_signed_by([key], media_type=media_type), now=1001 + ) def test_directory_signatures_require_the_exact_component_identifiers(): - signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) + signed = ed25519.Ed25519PrivateKey.generate() + assert verify_directory_response(ORIGIN, _directory_signed_by([signed]), now=1001) for components in ( ['"@authority"', '"content-digest"'], ['"@authority"'], ['"@authority";req', '"content-digest";key="sha-256"'], ): - with pytest.raises(WbaVerificationError): + with pytest.raises(WbaVerificationError, match="bind authority and digest"): verify_directory_response( - ORIGIN, - _partly_signed_directory(signed, unsigned, components=components)[0], - now=1001, + ORIGIN, _directory_signed_by([signed], components=components), now=1001 ) From 51f0bfaf4ea8b26aedf4bff6569ba6c91967776d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 00:41:53 -0400 Subject: [PATCH 13/46] fix(signing): decode JWKs and base64url strictly with joserfc b64url_decode used the standard library's lenient decoder, which drops characters outside the alphabet and ignores padding and trailing bits. Verifier step 8 decodes the signer's JWK through public_key_from_jwk, so it accepted a JWK whose x member had "!!" inserted. Build b64url_decode and b64url_encode on joserfc.util, and import Ed25519 and P-256 JWKs with joserfc's OKPKey and ECKey through one helper, crypto.import_jwk. The JWS and governance paths use the same helper and decoder instead of their own copies. The SDK keeps one check that joserfc leaves open: a P-256 member is exactly 32 bytes (RFC 7518 section 6.2.1.2). --- src/adcp/governance.py | 14 +-- src/adcp/signing/crypto.py | 105 +++++++++++------------ src/adcp/signing/jws.py | 50 ++--------- tests/conformance/signing/test_crypto.py | 31 ++++++- 4 files changed, 92 insertions(+), 108 deletions(-) diff --git a/src/adcp/governance.py b/src/adcp/governance.py index d57e620de..ef2e4e750 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -35,10 +35,10 @@ from adcp._version import resolve_adcp_version from adcp.signing.brand_jwks import BrandJsonResolverError, _pick_agent from adcp.signing.canonical import canonicalize_target_uri -from adcp.signing.crypto import b64url_decode, b64url_encode +from adcp.signing.crypto import b64url_decode, b64url_encode, import_jwk from adcp.signing.etld import host_from from adcp.signing.jwks import AsyncCachingJwksResolver, AsyncJwksResolver, JwksResolver -from adcp.signing.jws import _AdcpJwsRegistry, _import_jwk +from adcp.signing.jws import _AdcpJwsRegistry from adcp.signing.replay import ReplayStore, supports_atomic_claim from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest @@ -211,9 +211,9 @@ def __init__(self, replay_store: ReplayStore) -> None: if not supports_atomic_claim(replay_store): raise TypeError("governance replay protection requires an atomic replay store") self._store = replay_store - self._bindings: dict[tuple[str, str, str], tuple[float, GovernanceReplayBinding | None]] = ( - {} - ) + self._bindings: dict[ + tuple[str, str, str], tuple[float, GovernanceReplayBinding | None] + ] = {} self._binding_lock = threading.RLock() self._claims_since_sweep = 0 @@ -1201,7 +1201,7 @@ def reject( try: jws.deserialize_compact( token, - _import_jwk(_public_jwk(jwk)), + import_jwk(_public_jwk(jwk)), registry=_governance_jws_registry(alg), ) verified = True @@ -1462,7 +1462,7 @@ def _governance_jws_registry(alg: str) -> JWSRegistry: def _jose_key(alg: str, key: Any) -> OKPKey | ECKey: """Import a cryptography private key object for ``alg`` into joserfc. - Public keys go through ``jws._import_jwk``, which also enforces the + Public keys go through ``crypto.import_jwk``, which also enforces the RFC 7518 P-256 coordinate length joserfc leaves open. """ if alg == "EdDSA": diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index b1ddd2ebd..a4e9687ea 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -15,13 +15,15 @@ from __future__ import annotations -import base64 -from typing import Any +from typing import Any, cast from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from http_message_signatures.exceptions import HTTPMessageSignaturesException +from joserfc.errors import JoseError +from joserfc.jwk import ECKey, OKPKey +from joserfc.util import urlsafe_b64decode, urlsafe_b64encode from adcp.signing._rfc9421 import ALGORITHMS, parse_dictionary, serialize, translate_legacy_binary @@ -32,20 +34,24 @@ ALG_ES256 = "ecdsa-p256-sha256" ALLOWED_ALGS: frozenset[str] = frozenset({ALG_ED25519, ALG_ES256}) -# Ed25519 raw public-key and ECDSA-P-256 coordinate lengths (both 32 bytes). -# Defensive byte-length check before the library's on-curve validation. -_RAW_KEY_BYTES = 32 +# RFC 7518 section 6.2.1.2: a P-256 coordinate (and section 6.2.2.1, the +# private scalar) is exactly 32 bytes. joserfc accepts a shorter spelling of +# the same number, so the length is checked here. +_P256_MEMBER_BYTES = 32 def b64url_decode(s: str) -> bytes: - """Decode base64url, tolerating missing padding.""" - pad = "=" * (-len(s) % 4) - return base64.urlsafe_b64decode(s + pad) + """Decode RFC 7515 base64url with ``joserfc``: no padding, one spelling. + + Characters outside the URL-safe alphabet, padding, and non-zero trailing + bits raise ``ValueError``. + """ + return urlsafe_b64decode(s.encode("ascii")) def b64url_encode(b: bytes) -> str: - """Encode bytes as base64url without padding.""" - return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") + """Encode bytes as base64url without padding (``joserfc``).""" + return urlsafe_b64encode(b).decode("ascii") def load_private_key_pem(pem: bytes, *, password: bytes | None = None) -> PrivateKey: @@ -86,68 +92,53 @@ def load_private_key_pem(pem: bytes, *, password: bytes | None = None) -> Privat ) if isinstance(key, ec.EllipticCurvePrivateKey) and not isinstance(key.curve, ec.SECP256R1): raise ValueError( - f"EC key curve {key.curve.name} is not supported — only " - f"P-256 (SECP256R1) is allowed" + f"EC key curve {key.curve.name} is not supported — only P-256 (SECP256R1) is allowed" ) return key -def public_key_from_jwk(jwk: dict[str, Any]) -> PublicKey: - """Reconstruct a public key from its JWK.""" +def import_jwk(jwk: dict[str, Any]) -> OKPKey | ECKey: + """Import an Ed25519 or P-256 JWK with ``joserfc``. + + ``joserfc`` decodes the members strictly and checks the key. An unusable + JWK raises ``ValueError``. + """ kty = jwk.get("kty") crv = jwk.get("crv") + key_type: type[OKPKey] | type[ECKey] if kty == "OKP" and crv == "Ed25519": - x_raw = b64url_decode(jwk["x"]) - if len(x_raw) != _RAW_KEY_BYTES: - raise ValueError(f"Ed25519 JWK x has length {len(x_raw)}, expected {_RAW_KEY_BYTES}") - return ed25519.Ed25519PublicKey.from_public_bytes(x_raw) - if kty == "EC" and crv == "P-256": - x_raw = b64url_decode(jwk["x"]) - y_raw = b64url_decode(jwk["y"]) - if len(x_raw) != _RAW_KEY_BYTES or len(y_raw) != _RAW_KEY_BYTES: - raise ValueError( - f"P-256 JWK x/y must be {_RAW_KEY_BYTES} bytes each, " - f"got x={len(x_raw)} y={len(y_raw)}" - ) - x = int.from_bytes(x_raw, "big") - y = int.from_bytes(y_raw, "big") - return ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key() - raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + key_type = OKPKey + elif kty == "EC" and crv == "P-256": + key_type = ECKey + else: + raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + try: + if key_type is ECKey: + for name in ("x", "y", "d") if "d" in jwk else ("x", "y"): + member = jwk.get(name) + if not isinstance(member, str) or len(b64url_decode(member)) != _P256_MEMBER_BYTES: + raise ValueError(f"P-256 JWK {name} must be {_P256_MEMBER_BYTES} bytes") + return key_type.import_key(jwk) + except (JoseError, ValueError, TypeError, KeyError) as exc: + raise ValueError(f"unusable {kty}/{crv} JWK: {exc}") from exc + + +def public_key_from_jwk(jwk: dict[str, Any]) -> PublicKey: + """Reconstruct a public key from its JWK (see :func:`import_jwk`).""" + return cast(PublicKey, import_jwk(jwk).public_key) def private_key_from_jwk(jwk: dict[str, Any], *, d_field: str = "d") -> PrivateKey: - """Reconstruct a private key from its JWK. + """Reconstruct a private key from its JWK (see :func:`import_jwk`). `d_field` lets callers read from a non-standard scalar field — the AdCP test vectors store the private half under `_private_d_for_test_only` to signal that the keys are not production-grade. """ - kty = jwk.get("kty") - crv = jwk.get("crv") - if kty == "OKP" and crv == "Ed25519": - d_raw = b64url_decode(jwk[d_field]) - if len(d_raw) != _RAW_KEY_BYTES: - raise ValueError(f"Ed25519 JWK d has length {len(d_raw)}, expected {_RAW_KEY_BYTES}") - return ed25519.Ed25519PrivateKey.from_private_bytes(d_raw) - if kty == "EC" and crv == "P-256": - d_raw = b64url_decode(jwk[d_field]) - x_raw = b64url_decode(jwk["x"]) - y_raw = b64url_decode(jwk["y"]) - if ( - len(d_raw) != _RAW_KEY_BYTES - or len(x_raw) != _RAW_KEY_BYTES - or len(y_raw) != _RAW_KEY_BYTES - ): - raise ValueError( - f"P-256 JWK d/x/y must be {_RAW_KEY_BYTES} bytes each, " - f"got d={len(d_raw)} x={len(x_raw)} y={len(y_raw)}" - ) - d = int.from_bytes(d_raw, "big") - x = int.from_bytes(x_raw, "big") - y = int.from_bytes(y_raw, "big") - pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()) - return ec.EllipticCurvePrivateNumbers(d, pub).private_key() - raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + if d_field not in jwk: + raise ValueError(f"JWK has no private member {d_field!r}") + private = import_jwk({**jwk, "d": jwk[d_field]}).private_key + return cast(PrivateKey, private) def alg_for_jwk(jwk: dict[str, Any]) -> str: diff --git a/src/adcp/signing/jws.py b/src/adcp/signing/jws.py index cf537cf7f..6a6c28cb4 100644 --- a/src/adcp/signing/jws.py +++ b/src/adcp/signing/jws.py @@ -37,12 +37,12 @@ UnsupportedKeyAlgorithmError, ) from joserfc.jwa import EdDSAAlgorithm, ESAlgorithm, JWSAlgModel -from joserfc.jwk import ECKey, OKPKey +from joserfc.jwk import OKPKey from joserfc.jws import JWSRegistry -from joserfc.util import urlsafe_b64decode, urlsafe_b64encode +from joserfc.util import urlsafe_b64encode from adcp.signing._strict_json import StrictJsonError, parse_strict_json -from adcp.signing.crypto import ALG_ED25519, ALG_ES256 +from adcp.signing.crypto import ALG_ED25519, ALG_ES256, b64url_decode, import_jwk from adcp.signing.jwks import AsyncJwksResolver, JwksResolver # JWS (RFC 7518) alg names mapped to the RFC 9421 names the rest of the SDK @@ -53,9 +53,6 @@ } 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.""" @@ -116,11 +113,6 @@ class _AdcpJwsRegistry(JWSRegistry): 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. @@ -170,7 +162,7 @@ def parse_compact_jws(token: str) -> tuple[str, str, bytes]: f"compact JWS protected header is not valid base64url JSON: {exc}" ) from exc try: - signature = _b64url_decode(compact.segments["signature"].decode("ascii")) + signature = b64url_decode(compact.segments["signature"].decode("ascii")) except ValueError as exc: raise JwsMalformedError(f"compact JWS signature is not valid base64url: {exc}") from exc return ( @@ -198,8 +190,7 @@ def parse_general_json_jws(doc: dict[str, Any]) -> tuple[str, str, bytes]: raise JwsMalformedError("JWS general JSON 'signatures' must be a non-empty array") if len(signatures) > 1: raise JwsMalformedError( - "JWS general JSON 'signatures' with multiple entries is not supported for " - "this profile" + "JWS general JSON 'signatures' with multiple entries is not supported for this profile" ) entry = signatures[0] if not isinstance(entry, dict): @@ -227,7 +218,7 @@ def _validate_header( CPU. """ try: - raw = _b64url_decode(b64_protected) + 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") @@ -253,33 +244,6 @@ def _validate_header( 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( *, b64_protected: str, @@ -295,7 +259,7 @@ def _verify_signature_and_decode_payload( The header and payload segments go to joserfc exactly as received, so the signing input is the wire bytes. """ - key = _import_jwk(jwk) + 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) diff --git a/tests/conformance/signing/test_crypto.py b/tests/conformance/signing/test_crypto.py index 1f9604817..d51729e43 100644 --- a/tests/conformance/signing/test_crypto.py +++ b/tests/conformance/signing/test_crypto.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import string from pathlib import Path import pytest @@ -159,5 +160,33 @@ def test_jwk_with_wrong_length_x_rejected() -> None: "crv": "Ed25519", "x": b64url_encode(b"\x00" * 31), # too short } - with pytest.raises(ValueError, match="length"): + with pytest.raises(ValueError, match="32 bytes"): public_key_from_jwk(bad) + + +_B64URL = string.ascii_uppercase + string.ascii_lowercase + string.digits + "-_" + + +@pytest.mark.parametrize( + "spoil", + [ + lambda x: x[:10] + "!!" + x[10:], # characters outside the alphabet + lambda x: x + "=", # padding + lambda x: x[:-1] + _B64URL[_B64URL.index(x[-1]) ^ 1], # same bytes, non-zero pad bits + ], + ids=["alphabet", "padding", "trailing-bits"], +) +def test_jwk_members_are_strict_base64url(spoil) -> None: + """RFC 7515 section 2 base64url: no padding, no other characters, one spelling. + + Verifier step 8 decodes the signer's JWK with ``public_key_from_jwk``, so a + lenient decoder would accept a key whose members a strict verifier refuses. + """ + from adcp.signing.crypto import b64url_decode + + jwk = {k: v for k, v in KEYS["test-ed25519-2026"].items() if not k.startswith("_")} + assert public_key_from_jwk(jwk) + with pytest.raises(ValueError): + public_key_from_jwk({**jwk, "x": spoil(jwk["x"])}) + with pytest.raises(ValueError): + b64url_decode(spoil(jwk["x"])) From 615055d9921bedb6433ac1c148d780a0b1e0be72 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 00:52:03 -0400 Subject: [PATCH 14/46] refactor(signing): take WBA keys and signature fields from the libraries Remove hand-written code that joserfc, http-sf, and http-message-signatures already provide: - public_directory_key exports the JWK with joserfc's OKPKey.as_dict and thumbprint. - jwk_thumbprint passes joserfc the members that the key type's registry marks as required, instead of its own RFC 7638 member table. It returns the base64url thumbprint, so thumbprint_key_id no longer decodes and re-encodes it. - A directory key's x member is checked by importing it with joserfc, and the nonce check decodes base64url strictly, so neither re-encodes a value to compare it. - The async signer and the verifier build Signature-Input with http-sf (signature_input_field) instead of an f-string. - sign_key_directory adds each key's signature to the same message with HTTPMessageSigner.sign(append_if_signature_exists=True) instead of joining the fields with ", ". --- src/adcp/signing/_rfc9421.py | 20 ++++++++- src/adcp/signing/publisher_pins.py | 38 ++++++++-------- src/adcp/signing/signer.py | 2 +- src/adcp/signing/verifier.py | 6 +-- src/adcp/signing/wba.py | 71 ++++++++++++------------------ 5 files changed, 67 insertions(+), 70 deletions(-) diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index 66576e00a..2bb327a7a 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -117,6 +117,21 @@ def serialize(value: Any) -> str: return http_sf.ser(value) +def signature_input_field(label: str, member: str) -> str: + """A ``Signature-Input`` value holding one member, serialized by http-sf. + + ``member`` is that member's own serialization, the ``@signature-params`` + value. Raises ``ValueError`` when ``label`` is not an RFC 8941 key. + """ + try: + parsed = http_sf.parse(member.encode("ascii"), tltype="list") + except (http_sf.StructuredFieldError, ValueError) as exc: + raise ValueError(f"malformed signature parameters: {exc}") from exc + if not isinstance(parsed, list) or len(parsed) != 1: + raise ValueError("signature parameters are not one member") + return serialize({label: parsed[0]}) + + def component_identifier(item: Any) -> str: """The serialized component identifier, e.g. ``"signature-agent";key="sig1"``.""" return ser_item(item) @@ -491,11 +506,13 @@ def sign( tag: str, covered_component_ids: Sequence[str], resolver: type[ComponentResolver], + append: bool = False, ) -> tuple[str, str]: """Sign ``message`` with ``HTTPMessageSigner.sign``; return the two fields. Returns ``(Signature-Input, Signature)``. ``Signature`` is RFC 8941 - standard Base64; a legacy profile re-encodes it. + standard Base64; a legacy profile re-encodes it. With ``append``, a + signature already on ``message`` stays, and this one is added beside it. """ signer = ProfileSigner( signature_algorithm=ALGORITHMS[alg], @@ -512,6 +529,7 @@ def sign( label=label, tag=tag, covered_component_ids=covered_component_ids, + append_if_signature_exists=append, ) except ComponentError as exc: raise ValueError(exc.reason) from exc diff --git a/src/adcp/signing/publisher_pins.py b/src/adcp/signing/publisher_pins.py index c5e807199..e8d014548 100644 --- a/src/adcp/signing/publisher_pins.py +++ b/src/adcp/signing/publisher_pins.py @@ -8,36 +8,34 @@ from joserfc.errors import JoseError from joserfc.jwk import ECKey, OKPKey, RSAKey -from joserfc.util import urlsafe_b64decode PublisherPins = Mapping[str, Sequence[Mapping[str, Any]] | None] -#: The public members RFC 7638 hashes, per key type. A key missing one (or -#: carrying a non-string one) has no thumbprint and matches nothing. -_THUMBPRINT_MEMBERS: dict[ - str, tuple[type[OKPKey] | type[ECKey] | type[RSAKey], tuple[str, ...]] -] = { - "OKP": (OKPKey, ("crv", "x")), - "EC": (ECKey, ("crv", "x", "y")), - "RSA": (RSAKey, ("e", "n")), +_KEY_TYPES: dict[str, type[OKPKey] | type[ECKey] | type[RSAKey]] = { + "OKP": OKPKey, + "EC": ECKey, + "RSA": RSAKey, } -def jwk_thumbprint(key: Mapping[str, Any]) -> bytes | None: - """Return the RFC 7638 digest (``joserfc``); incomplete public keys match nothing. +def jwk_thumbprint(key: Mapping[str, Any]) -> str | None: + """Return the RFC 7638 thumbprint (``joserfc``); incomplete public keys match nothing. - Only the public members go to ``joserfc``: a pin or directory entry is a - public key, and the thumbprint never depends on anything else. + Only the members RFC 7638 hashes (the key type's required members in + ``joserfc``) go to ``joserfc``: a pin or directory entry is a public key, + and the thumbprint never depends on anything else. ``joserfc`` refuses a + key missing one of them or carrying a malformed one. """ - shape = _THUMBPRINT_MEMBERS.get(str(key.get("kty"))) - if shape is None: - return None - key_type, members = shape - if any(not isinstance(key.get(m), str) or not key[m] for m in members): + key_type = _KEY_TYPES.get(str(key.get("kty"))) + if key_type is None: return None + public = { + name: key[name] + for name, member in key_type.value_registry.items() + if member.required and name in key + } try: - imported = key_type.import_key({"kty": key["kty"], **{m: key[m] for m in members}}) - return urlsafe_b64decode(imported.thumbprint().encode("ascii")) + return key_type.import_key({"kty": key["kty"], **public}).thumbprint() except (JoseError, ValueError, TypeError): return None diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index e3b0f09b9..cbf45cea4 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -357,7 +357,7 @@ async def async_sign_request( signature = await provider.sign(base) return _signed_headers( prepared, - signature_input=f"{label}={member}", + signature_input=_rfc9421.signature_input_field(label, member), signature=signature, label=label, signing_profile_version=signing_profile_version, diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index ab4089d11..9c6fbc16f 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -454,7 +454,7 @@ def verify_request_signature( url=url, headers={ **{k: v for k, v in headers.items() if k.lower() not in _SIGNATURE_FIELDS}, - "Signature-Input": f"{options.label}={parsed.raw_value}", + "Signature-Input": _rfc9421.signature_input_field(options.label, parsed.raw_value), "Signature": format_signature_header(sig_bytes, label=options.label), }, ) @@ -875,9 +875,7 @@ def _check_key_purpose( raise SignatureVerificationError( REQUEST_SIGNATURE_KEY_PURPOSE_INVALID, step=8, - message=( - f"JWK.adcp_use {jwk.get('adcp_use')!r} not in " f"{sorted(allowed_adcp_uses)!r}" - ), + message=(f"JWK.adcp_use {jwk.get('adcp_use')!r} not in {sorted(allowed_adcp_uses)!r}"), ) try: jwk_alg = alg_for_jwk(dict(jwk)) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index f17ed7607..a973272cf 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -25,7 +25,6 @@ import asyncio import base64 -import binascii import json import secrets import time @@ -37,15 +36,15 @@ from urllib.parse import urlsplit import httpx -from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 +from joserfc.jwk import OKPKey from adcp.signing import _rfc9421 from adcp.signing._bounded_http import async_read_limited_bytes from adcp.signing._header_precheck import strict_header_precheck from adcp.signing._strict_json import StrictJsonError, parse_strict_json from adcp.signing.canonical import _lookup, canonical_origin -from adcp.signing.crypto import b64url_decode, b64url_encode, public_key_from_jwk +from adcp.signing.crypto import b64url_decode, b64url_encode, import_jwk, public_key_from_jwk from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches from adcp.signing.jwks import SSRFValidationError, validate_uri_static from adcp.signing.publisher_pins import jwk_thumbprint @@ -114,20 +113,17 @@ def is_64_byte_nonce(nonce: object) -> bool: """ 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): + # Unpadded base64url, decoded strictly (one spelling per value). + return len(b64url_decode(nonce)) == WBA_NONCE_BYTES + except 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) + except ValueError: + return False + return len(raw) == WBA_NONCE_BYTES and base64.b64encode(raw).decode("ascii") == nonce def valid_wba_nonce(nonce: object) -> bool: @@ -285,10 +281,10 @@ def _https_origin(url: str) -> str: def thumbprint_key_id(jwk: Mapping[str, Any]) -> str: """Return the RFC 7638 thumbprint (``joserfc``) for supported public key material.""" - digest = jwk_thumbprint(jwk) - if digest is None: + thumbprint = jwk_thumbprint(jwk) + if thumbprint is None: raise ValueError("incomplete public JWK") - return b64url_encode(digest) + return thumbprint WbaPrivateKey = ed25519.Ed25519PrivateKey @@ -301,25 +297,8 @@ def public_directory_key(private_key: WbaPrivateKey) -> dict[str, Any]: """ if not isinstance(private_key, ed25519.Ed25519PrivateKey): raise ValueError("Web Bot Auth signs with Ed25519 keys only") - public = private_key.public_key() - jwk: dict[str, Any] = { - "kty": "OKP", - "crv": "Ed25519", - "x": b64url_encode( - public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) - ), - } - 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 + key = OKPKey.import_key(private_key) + return {**key.as_dict(private=False), "kid": key.thumbprint(), "use": "sig"} def _algorithm(jwk: Mapping[str, Any]) -> str: @@ -335,9 +314,11 @@ def _algorithm(jwk: Mapping[str, Any]) -> str: or _PRIVATE_JWK_MEMBERS & set(jwk) ): raise WbaVerificationError("unsupported directory key") - # One key, one thumbprint: key members must be canonical base64url. - if not _canonical_b64url(jwk.get("x")): - raise WbaVerificationError("directory key member is not canonical base64url") + # One key, one thumbprint: joserfc refuses a non-canonical x. + try: + import_jwk({name: jwk.get(name) for name in ("kty", "crv", "x")}) + except ValueError as exc: + raise WbaVerificationError("invalid directory key member") from exc if jwk.get("alg") not in {None, "EdDSA"}: raise WbaVerificationError("directory key algorithm mismatch") if "key_ops" in jwk and ( @@ -728,12 +709,15 @@ def sign_key_directory( created, expires = now - DIRECTORY_SKEW_ALLOWANCE_SECONDS, now + expires_in_seconds else: expires = created + expires_in_seconds - request = _rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH) - inputs: list[str] = [] - signatures: list[str] = [] + message = _rfc9421.Response( + status_code=200, + request=_rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH), + headers=dict(headers), + ) for number, (key, jwk) in enumerate(zip(private_keys, keys), 1): + # Each key adds its own member to the same two fields. signature_input, signature = _rfc9421.sign( - _rfc9421.Response(status_code=200, request=request, headers=dict(headers)), + message, alg=_algorithm(jwk), private_key=key, key_id=jwk["kid"], @@ -744,10 +728,9 @@ def sign_key_directory( tag=DIRECTORY_TAG, covered_component_ids=['"@authority";req', '"content-digest"'], resolver=_WbaComponentResolver, + append=True, ) - inputs.append(signature_input) - signatures.append(signature) - headers.update({"Signature-Input": ", ".join(inputs), "Signature": ", ".join(signatures)}) + headers.update({"Signature-Input": signature_input, "Signature": signature}) return DirectoryResponse(body=body, headers=headers) From ee94e3bb0d6c3b44ad055b6a026337a71bb1ee26 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:49:06 -0400 Subject: [PATCH 15/46] test(signing): pin which Signature-Agent member types the verifier reads Under -00 section 5.2.1, a Signature-Agent member with type=directory names a key directory, and the verifier ignores a member with any other type (profile rule 6). The verifier already reads type=directory as a Token and as a String, as Cloudflare's parser does, and ignores jwks_uri and cimd, which leaves the signature without a member keyed to its label (403, harness scenarios R18 and R18b). No test covered either spelling. The nonce test's signing moves into a helper, _sign_vector_request, that signs the positive vector's request with the library, so a test can sign a Signature-Agent shape the SDK signer never emits. --- docs/agent-identity-3-3-migration.md | 2 +- tests/conformance/signing/test_wba_profile.py | 65 +++++++++++++++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 8bca68012..b61be5a58 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -99,7 +99,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. A `Signature-Agent` member whose `type` parameter is not `directory` (a Token or a String) is ignored, so the signature has no member keyed to its label (403). - **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@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). - `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. diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index bbfc64c5d..b004ea5ee 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -796,8 +796,13 @@ async def verify(): assert "Accept-Signature" in caught.value.headers -async def test_short_received_nonce_is_a_challenge(): - """A nonce shorter than 64 bytes fails the profile: 403 with Accept-Signature.""" +def _sign_vector_request(*, nonce="A" * 86, signature_agent=None): + """The positive/001 request, re-signed by the library with ``nonce``. + + ``signature_agent`` replaces the covered Signature-Agent field before + signing, so a shape the SDK signer never emits still carries a valid + signature. + """ from adcp.signing import _rfc9421 from adcp.signing.wba import _WbaComponentResolver @@ -805,8 +810,11 @@ async def test_short_received_nonce_is_a_challenge(): private = private_key_from_jwk(key, d_field="_private_d_for_test_only") vector = json.loads((ROOT / "positive/001-single-signer-request.json").read_text()) request = vector["request"] - headers = {k: v for k, v in request["headers"].items() if not k.startswith("Signature-I")} - headers = {k: v for k, v in headers.items() if k != "Signature"} + headers = { + k: v for k, v in request["headers"].items() if k not in {"Signature-Input", "Signature"} + } + if signature_agent is not None: + headers["Signature-Agent"] = signature_agent signature_input, signature = _rfc9421.sign( _rfc9421.Request(request["method"], request["url"], dict(headers)), alg="ed25519", @@ -815,7 +823,7 @@ async def test_short_received_nonce_is_a_challenge(): label="sig1", created=1790841600, expires=1790841900, - nonce="A" * 22, + nonce=nonce, tag="web-bot-auth", covered_component_ids=[ '"@method"', @@ -828,8 +836,9 @@ async def test_short_received_nonce_is_a_challenge(): resolver=_WbaComponentResolver, ) headers.update({"Signature-Input": signature_input, "Signature": signature}) - with pytest.raises(WbaVerificationError, match="nonce") as caught: - await verify_wba_request( + + async def verify(headers=headers): + return await verify_wba_request( method=request["method"], url=request["url"], headers=headers, @@ -838,6 +847,48 @@ async def test_short_received_nonce_is_a_challenge(): replay_store=InMemoryReplayStore(), now=vector["reference_now"], ) + + return headers, verify + + +async def test_short_received_nonce_is_a_challenge(): + """A nonce shorter than 64 bytes fails the profile: 403 with Accept-Signature.""" + _headers, verify = _sign_vector_request() + (signer,) = await verify() + assert signer.agent_url == "https://buyer-7k3q.com" + _headers, verify = _sign_vector_request(nonce="A" * 22) + with pytest.raises(WbaVerificationError, match="nonce") as caught: + await verify() + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers + + +@pytest.mark.parametrize( + ("parameter", "verifies"), + [ + ("type=directory", True), + ('type="directory"', True), + ("type=jwks_uri", False), + ('type="jwks_uri"', False), + ("type=cimd", False), + ], +) +async def test_signature_agent_member_type_must_be_directory(parameter, verifies): + """A ``type`` other than ``directory`` makes the verifier ignore the member. + + ``directory`` is the -00 section 5.2.1 type, as a Token or as a String, + as Cloudflare's parser reads it. An ignored member leaves the signature + without a member keyed to its label: 403 with Accept-Signature. + """ + _headers, verify = _sign_vector_request( + signature_agent=f'sig1="https://buyer-7k3q.com";{parameter}' + ) + if verifies: + (signer,) = await verify() + assert signer.agent_url == "https://buyer-7k3q.com" + return + with pytest.raises(WbaVerificationError, match="Signature-Agent member") as caught: + await verify() assert caught.value.status == 403 assert "Accept-Signature" in caught.value.headers From e10a3a0415c334e0a007e088520d94211a880ef5 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:33:52 -0400 Subject: [PATCH 16/46] fix(signing): refuse a malformed member in any signature field RFC 9421 sections 4.1 and 4.2 define every Signature-Input member as an inner list and every Signature member as a Byte Sequence with no parameters. The verifier checked this only for the web-bot-auth member, so a sibling such as cdn=token verified. Cloudflare's http-message-sig refuses such a field as MalformedField, which the profile answers with 400 (harness scenario R20b). _check_signature_members runs on both fields before a member is selected, for requests and for key directories, so a malformed web-bot-auth member is 400 too. A member with another tag is still not read otherwise. _verify_crypto no longer checks its own member. --- docs/agent-identity-3-3-migration.md | 2 +- src/adcp/signing/wba.py | 36 ++++++++------- tests/conformance/signing/test_wba_interop.py | 3 +- tests/conformance/signing/test_wba_profile.py | 44 +++++++++++++++++++ tests/test_identity_3_3_alignment.py | 18 ++++++++ 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index b61be5a58..b446e66ed 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -99,7 +99,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. A `Signature-Agent` member whose `type` parameter is not `directory` (a Token or a String) is ignored, so the signature has no member keyed to its label (403). +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. A `Signature-Agent` member whose `type` parameter is not `directory` (a Token or a String) is ignored, so the signature has no member keyed to its label (403). Every `Signature-Input` member must be an inner list and every `Signature` member a Byte Sequence with no parameters, including members with other tags that the verifier doesn't read; otherwise the field is malformed (400). Key directory verification applies the same rule. - **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@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). - `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. diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index a973272cf..c86f6bb3d 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -249,6 +249,19 @@ def _dictionary(value: str | None, *, missing_status: int = 400) -> dict[str, An raise WbaVerificationError(f"malformed signature field: {exc}", status=400) from exc +def _check_signature_members(inputs: Mapping[str, Any], signatures: Mapping[str, Any]) -> None: + """RFC 9421 sections 4.1 and 4.2: the shape of every member, read or not. + + Each ``Signature-Input`` member is an inner list, and each ``Signature`` + member a Byte Sequence with no parameters. A member another party added + is not read, but a malformed one makes the field malformed: 400. + """ + if not all(_rfc9421.is_inner_list(member) for member in inputs.values()) or any( + type(value) is not bytes or params for value, params in signatures.values() + ): + raise WbaVerificationError("malformed signature field member", status=400) + + def uses_web_bot_auth(headers: Mapping[str, str]) -> bool: """Select WBA from either marker; never fall back after a WBA failure.""" if _lookup(headers, "signature-agent") is not None: @@ -423,20 +436,18 @@ def _verify_crypto( *, label: str, params: Mapping[str, Any], - signature: Any, jwk: Mapping[str, Any], ) -> str: """Verify ``label`` with ``http-message-signatures``; return the algorithm. The algorithm comes from the key (:func:`_algorithm`), and the signature's - ``alg`` must name the same one. + ``alg`` must name the same one. The caller checked the fields' shape + (:func:`_check_signature_members`). """ alg = _algorithm(jwk) # Every signature names its algorithm (Profile rules); none is inferred. if params.get("alg") != alg: raise WbaVerificationError("signature algorithm is absent or does not match key") - if not isinstance(signature, tuple) or not isinstance(signature[0], bytes) or signature[1]: - raise WbaVerificationError("signature must be a bare sf-binary", status=400) try: public_key = public_key_from_jwk(dict(jwk)) except (ValueError, KeyError, TypeError) as exc: @@ -625,6 +636,7 @@ def _verify_directory( signatures = _dictionary(raw_signatures) if set(signatures) != set(inputs): raise WbaVerificationError("directory signature labels differ") + _check_signature_members(inputs, signatures) message = _rfc9421.Response( status_code=response.status, request=_rfc9421.Request(method="GET", url=origin + DIRECTORY_PATH), @@ -650,9 +662,7 @@ def _verify_directory( keyid = params["keyid"] if keyid not in keys or keyid in signed: raise WbaVerificationError("directory signature key is absent or repeated") - _verify_crypto( - message, label=label, params=params, signature=signatures.get(label), jwk=keys[keyid] - ) + _verify_crypto(message, label=label, params=params, jwk=keys[keyid]) signed[keyid] = keys[keyid] earliest = min(earliest, float(params["expires"])) # One signature per key (Appendix B.1): every usable key proves possession. @@ -1038,15 +1048,13 @@ async def verify_wba_request( # parse is a 400. inputs = _dictionary(_lookup(headers, "signature-input"), missing_status=403) signatures = _dictionary(_lookup(headers, "signature"), missing_status=403) + # A malformed member is a malformed field, before any member is selected. + _check_signature_members(inputs, signatures) raw_agent = _lookup(headers, "signature-agent") # Selection by tag alone leaves the Signature-Agent member absent, which # fails the signature with 403 below. agents = _dictionary(raw_agent) if raw_agent is not None else {} - wba = [ - (label, member) - for label, member in inputs.items() - if _rfc9421.is_inner_list(member) and member[1].get("tag") == WBA_TAG - ] + wba = [(label, member) for label, member in inputs.items() if member[1].get("tag") == WBA_TAG] if not wba: raise WbaVerificationError("Signature-Agent requires the Web Bot Auth profile") if set(signatures) != set(inputs): @@ -1114,9 +1122,7 @@ async def verify_wba_request( assert key is not None message = _rfc9421.Request(method=method, url=url, headers=dict(headers)) try: - alg = _verify_crypto( - message, label=label, params=params, signature=signatures.get(label), jwk=key.jwk - ) + alg = _verify_crypto(message, label=label, params=params, jwk=key.jwk) except WbaVerificationError as exc: exc.label = label raise diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index 23f7f3eaa..11f663283 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -206,7 +206,6 @@ def test_rsa_pss_appendix_e_vector_is_not_a_web_bot_auth_signature(): f'"@signature-params": {APPENDIX_E_INPUT.removeprefix("sig2=")}' ) _components, params = parse_dictionary(APPENDIX_E_INPUT)["sig2"] - signature = parse_dictionary(APPENDIX_E_SIGNATURE)["sig2"] message = Request("GET", "https://example.com/", headers) with pytest.raises(WbaVerificationError, match="unsupported directory key"): - _verify_crypto(message, label="sig2", params=params, signature=signature, jwk=jwk) + _verify_crypto(message, label="sig2", params=params, jwk=jwk) diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index b004ea5ee..d8a76ec14 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -893,6 +893,50 @@ async def test_signature_agent_member_type_must_be_directory(parameter, verifies assert "Accept-Signature" in caught.value.headers +@pytest.mark.parametrize( + ("cdn_input", "cdn_signature", "verifies"), + [ + ('("@method" "@authority");tag="cdn-edge"', ":AAAA:", True), + ('("@method" "@authority");tag="cdn-edge"', "edge-token", False), + ('("@method" "@authority");tag="cdn-edge"', '"edge"', False), + ('("@method" "@authority");tag="cdn-edge"', ":AAAA:;x=1", False), + ('("@method" "@authority");tag="cdn-edge"', "(1 2)", False), + ('edge-token;tag="cdn-edge"', ":AAAA:", False), + ("edge-token", ":AAAA:", False), + ], +) +async def test_every_signature_field_member_is_well_formed(cdn_input, cdn_signature, verifies): + """RFC 9421 sections 4.1 and 4.2: the shape of every member, read or not. + + Each ``Signature-Input`` member is an inner list, and each ``Signature`` + member a Byte Sequence with no parameters. A member with another tag is + not read, but the fields still parse: a malformed member is 400. + """ + headers, verify = _sign_vector_request() + appended = { + **headers, + "Signature-Input": f"{headers['Signature-Input']}, cdn={cdn_input}", + "Signature": f"{headers['Signature']}, cdn={cdn_signature}", + } + if verifies: + (signer,) = await verify(appended) + assert signer.agent_url == "https://buyer-7k3q.com" + return + with pytest.raises(WbaVerificationError, match="malformed signature field member") as caught: + await verify(appended) + assert caught.value.status == 400 + + +@pytest.mark.parametrize("field", ["Signature-Input", "Signature"]) +async def test_a_malformed_web_bot_auth_member_is_400(field): + """The signature's own member, malformed, is a malformed field: 400.""" + headers, verify = _sign_vector_request() + malformed = {"Signature-Input": 'sig1=token;tag="web-bot-auth"', "Signature": "sig1=token"} + with pytest.raises(WbaVerificationError, match="malformed signature field member") as caught: + await verify({**headers, field: malformed[field]}) + assert caught.value.status == 400 + + @pytest.mark.parametrize( ("revocation_state", "operation"), [ diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 5210eb653..77788aec1 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -822,6 +822,24 @@ def test_directory_media_type_is_the_wba_one(media_type): ) +def test_every_directory_signature_member_is_a_byte_sequence(): + """RFC 9421 section 4.2 holds for a member the verifier ignores too.""" + from dataclasses import replace + + ed_key = ed25519.Ed25519PrivateKey.generate() + rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + response = _directory_signed_by([ed_key, rsa_key]) + assert verify_directory_response(ORIGIN, response, now=1001) + # key2 is the RSA key's member: its signature is not read. + signature = response.headers["Signature"].split(", key2=")[0] + ", key2=token" + with pytest.raises(WbaVerificationError, match="malformed signature field member"): + verify_directory_response( + ORIGIN, + replace(response, headers={**response.headers, "Signature": signature}), + now=1001, + ) + + def test_directory_signatures_require_the_exact_component_identifiers(): signed = ed25519.Ed25519PrivateKey.generate() assert verify_directory_response(ORIGIN, _directory_signed_by([signed]), now=1001) From 4def0dcf3034918d699f168618b6a40911c3575d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:51:08 -0400 Subject: [PATCH 17/46] fix(signing): accept only the canonical origin string as Signature-Agent The Signature-Agent member value is the signer's https origin, and the profile compares it as a string: a lowercase host, no default port, no trailing slash, and no path, query, fragment, or userinfo. The verifier canonicalized the value first, so https://BUYER.example.com:443/agents/a verified as https://buyer.example.com. The verifier now refuses any value that differs from its canonical origin with 403, before key discovery (harness scenarios R17, R17c, R17d, and R17e). http:// and userinfo were already refused. sign_wba_request already sends the canonical origin, and a test pins it. --- docs/agent-identity-3-3-migration.md | 2 +- src/adcp/signing/wba.py | 4 ++ tests/conformance/signing/test_wba_profile.py | 55 ++++++++++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index b446e66ed..e07111ba2 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -99,7 +99,7 @@ Duplicate structured-field parameters, dictionary labels, and `Signature-Agent` ### Interop -- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. A `Signature-Agent` member whose `type` parameter is not `directory` (a Token or a String) is ignored, so the signature has no member keyed to its label (403). Every `Signature-Input` member must be an inner list and every `Signature` member a Byte Sequence with no parameters, including members with other tags that the verifier doesn't read; otherwise the field is malformed (400). Key directory verification applies the same rule. +- **One signature.** A request carries exactly one `web-bot-auth` signature and exactly one `Signature-Agent` member, keyed to that signature's label. Relays are gone: an intermediary that signs is the signer, and whether it may act for another party is an authorization question, not a second signature. `verify_wba_request` still returns a tuple, now always of one signer. `Signature-Agent` is read as a Structured Fields Dictionary only; the older bare sf-string form is not part of the final profile. The member value is the signer's https origin in canonical form, compared as a string: a lowercase host, no default port, no trailing `/`, and no path, query, fragment, or userinfo. Any other spelling is refused with 403 before key discovery, and `sign_wba_request` sends the canonical form. A `Signature-Agent` member whose `type` parameter is not `directory` (a Token or a String) is ignored, so the signature has no member keyed to its label (403). Every `Signature-Input` member must be an inner list and every `Signature` member a Byte Sequence with no parameters, including members with other tags that the verifier doesn't read; otherwise the field is malformed (400). Key directory verification applies the same rule. - **Nonce.** The nonce encodes exactly 64 bytes. The profile spells it as unpadded base64url; padded standard base64 of 64 bytes is also accepted, because Cloudflare's `web-bot-auth` signer and the draft's Appendix E vectors send it. The page has no shorter-nonce exception for `required_for` operations or reads. - `@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). - `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. diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index c86f6bb3d..9e25d9ef7 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1093,6 +1093,10 @@ async def verify_wba_request( origin = _https_origin(agent[0]) except ValueError as exc: raise WbaVerificationError("invalid signer origin", label=label) from exc + # The value is the serialized origin itself, compared as a string: a + # spelling that only normalizes to an origin names no origin. + if agent[0] != origin: + raise WbaVerificationError("signer origin is not in canonical form", label=label) covered = [_rfc9421.component_identifier(item) for item in items] required = {_rfc9421.component_identifier((name, {})) for name in _REQUEST_COMPONENTS} required.add(_rfc9421.component_identifier(("signature-agent", {"key": label}))) diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index d8a76ec14..39331770e 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -837,13 +837,13 @@ def _sign_vector_request(*, nonce="A" * 86, signature_agent=None): ) headers.update({"Signature-Input": signature_input, "Signature": signature}) - async def verify(headers=headers): + async def verify(headers=headers, signer_keys=None): return await verify_wba_request( method=request["method"], url=request["url"], headers=headers, body=request["body"].encode(), - signer_keys=_vector_resolver(vector), + signer_keys=signer_keys or _vector_resolver(vector), replay_store=InMemoryReplayStore(), now=vector["reference_now"], ) @@ -863,6 +863,57 @@ async def test_short_received_nonce_is_a_challenge(): assert "Accept-Signature" in caught.value.headers +@pytest.mark.parametrize( + "value", + [ + "http://buyer-7k3q.com", + "https://buyer-7k3q.com/agents/a", + "https://buyer-7k3q.com/", + "https://BUYER-7k3q.com", + "https://buyer-7k3q.com:443", + "https://buyer-7k3q.com?x=1", + "https://buyer-7k3q.com#f", + "https://agent@buyer-7k3q.com", + ], +) +async def test_signature_agent_is_exactly_the_canonical_https_origin(value): + """The member value is the serialized https origin, compared as a string. + + A lowercase host, no default port, no trailing ``/``, and no path, query, + fragment, or userinfo. Anything else is 403 before key discovery, even + when the origin it normalizes to serves the key. + """ + headers, verify = _sign_vector_request() + assert headers["Signature-Agent"] == 'sig1="https://buyer-7k3q.com"' + (signer,) = await verify() + assert signer.agent_url == "https://buyer-7k3q.com" + fetched = [] + + async def record(keyid, *, signer_origin=None): + fetched.append(signer_origin) + + _headers, verify = _sign_vector_request(signature_agent=f'sig1="{value}"') + with pytest.raises(WbaVerificationError, match="origin") as caught: + await verify(signer_keys=record) + assert caught.value.status == 403 + assert "Accept-Signature" in caught.value.headers + assert fetched == [] + + +def test_signer_sends_the_canonical_origin(): + key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] + headers = sign_wba_request( + method="GET", + url="https://seller-7k3q.com/mcp/", + headers={}, + body=b"", + private_key=private_key_from_jwk(key, d_field="_private_d_for_test_only"), + agent_origin="https://BUYER-7k3q.com:443/", + created=1790841600, + ) + assert headers["Signature-Agent"] == 'sig1="https://buyer-7k3q.com"' + + @pytest.mark.parametrize( ("parameter", "verifies"), [ From 021824b5bd6f8ccc8310c1635698f01989b9aea9 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:35:35 -0400 Subject: [PATCH 18/46] test(signing): grade the governance issuer check outside the WBA profile The governance vectors leave the Web Bot Auth profile's vectors, and the profile drops its governance checks: verify_wba_governance_issuer is an AdCP-layer check that runs after WBA verification. The function stays. Move the three governance vectors, with the e24eeb09 keys file they sign with, to vectors/wba-governance, with their own provenance record. Move their tests from test_wba_profile.py to test_wba_governance.py, which shares the profile tests' directory resolver. --- .../signing/test_wba_governance.py | 125 ++++++++++++++++++ tests/conformance/signing/test_wba_profile.py | 110 +-------------- .../001-governance-token.json | 0 ...nce-token-key-not-in-issuer-directory.json | 0 ...nance-token-from-non-governance-agent.json | 0 .../vectors/wba-governance/UPSTREAM.json | 19 +++ .../vectors/wba-governance/keys.json | 35 +++++ .../vectors/wba-profile/UPSTREAM.json | 3 - 8 files changed, 183 insertions(+), 109 deletions(-) create mode 100644 tests/conformance/signing/test_wba_governance.py rename tests/conformance/vectors/{wba-profile/governance => wba-governance}/001-governance-token.json (100%) rename tests/conformance/vectors/{wba-profile/governance => wba-governance}/002-governance-token-key-not-in-issuer-directory.json (100%) rename tests/conformance/vectors/{wba-profile/governance => wba-governance}/003-governance-token-from-non-governance-agent.json (100%) create mode 100644 tests/conformance/vectors/wba-governance/UPSTREAM.json create mode 100644 tests/conformance/vectors/wba-governance/keys.json diff --git a/tests/conformance/signing/test_wba_governance.py b/tests/conformance/signing/test_wba_governance.py new file mode 100644 index 000000000..e5c0134ee --- /dev/null +++ b/tests/conformance/signing/test_wba_governance.py @@ -0,0 +1,125 @@ +"""The governance-token issuer check that runs after Web Bot Auth verification. + +``verify_wba_governance_issuer`` checks that a governance token's key is in +the ``iss`` origin's key directory and that ``iss`` is the buyer's governance +agent. It is an AdCP-layer check, not part of the WBA profile, so its vectors +live apart from the profile's (``vectors/wba-governance``). +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from adcp.signing.crypto import private_key_from_jwk +from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer +from tests.conformance.signing.test_wba_profile import _vector_resolver + +ROOT = Path(__file__).parents[1] / "vectors" / "wba-governance" +KEYS = {key["kid"]: key for key in json.loads((ROOT / "keys.json").read_text())["keys"]} +VECTORS = sorted(ROOT.glob("0*.json")) +GOVERNANCE_KID = "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE" + + +def test_vendored_vectors_match_recorded_provenance(): + upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) + assert upstream["commit"] == "e24eeb090c4365707641099cbbb45f9945c6d6e0" + vendored = {path.name for path in ROOT.iterdir() if path.name != "UPSTREAM.json"} + assert vendored == set(upstream["sha256"]) + for name, digest in upstream["sha256"].items(): + assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest + + +@pytest.mark.parametrize("path", VECTORS, ids=lambda p: p.name) +async def test_governance_vectors(path): + vector = json.loads(path.read_text()) + + async def verify(): + return await verify_wba_governance_issuer( + vector["jws"], + signer_keys=_vector_resolver(vector, KEYS), + governance_agents=vector["role_listing"]["adcp:governance"], + now=vector["reference_now"], + ) + + expected = vector["expected_outcome"] + if expected["success"]: + assert await verify() == expected["issuer"] + return + with pytest.raises(WbaGovernanceError) as caught: + await verify() + assert caught.value.code == expected["error_code"] + + +def _governance_token(header_extra=None, claims_extra=None, *, iss=None): + from joserfc import jws as jose_jws + from joserfc.jwk import OKPKey + + from adcp.signing.wba import _governance_registry + + identity = iss or KEYS[GOVERNANCE_KID]["identity"] + header = { + "alg": "EdDSA", + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": GOVERNANCE_KID, + **(header_extra or {}), + } + claims = {"iss": identity, "aud": "https://seller-7k3q.com/mcp/", "iat": 1000, "exp": 1600} + claims.update(claims_extra or {}) + private = private_key_from_jwk(KEYS[GOVERNANCE_KID], d_field="_private_d_for_test_only") + return jose_jws.serialize_compact( + header, + json.dumps(claims).encode(), + OKPKey.import_key(private), + registry=_governance_registry("EdDSA"), + ) + + +@pytest.mark.parametrize("markers", ["matched", "claim_without_marker", "task_without_hash"]) +async def test_governance_action_binding_markers(markers): + """Action-authorizing tokens carry crit markers (security.mdx checklist step 4).""" + vector = json.loads((ROOT / "001-governance-token.json").read_text()) + binding = {"authorized_task": "create_media_buy", "authorized_payload_hash": "h"} + header = {"crit": list(binding), **{name: True for name in binding}} + if markers == "claim_without_marker": + header = {} + claims = dict(binding) + if markers == "task_without_hash": + header = {"crit": ["authorized_task"], "authorized_task": True} + claims = {"authorized_task": "create_media_buy"} + token = _governance_token(header, claims) + + async def verify(): + return await verify_wba_governance_issuer( + token, + signer_keys=_vector_resolver(vector, KEYS), + governance_agents=vector["role_listing"]["adcp:governance"], + now=1100, + ) + + if markers == "matched": + assert await verify() == "https://governance-7k3q.com" + return + with pytest.raises(WbaGovernanceError) as caught: + await verify() + assert caught.value.code == "governance_token_invalid" + + +async def test_governance_issuer_is_checked_before_key_discovery(): + token = _governance_token(iss="https://buyer-7k3q.com") + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("an unlisted issuer must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, + signer_keys=resolve, + governance_agents=["https://governance-7k3q.com"], + now=1100, + ) + assert caught.value.code == "governance_issuer_not_authorized" diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 39331770e..f97c9a9d9 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -44,17 +44,15 @@ ROOT = Path(__file__).parents[1] / "vectors" / "wba-profile" KEYS = {key["kid"]: key for key in json.loads((ROOT / "keys.json").read_text())["keys"]} VECTORS = sorted((ROOT / "positive").glob("*.json")) + sorted((ROOT / "negative").glob("*.json")) -GOVERNANCE_VECTORS = sorted((ROOT / "governance").glob("*.json")) -def _public(kid): - return {k: v for k, v in KEYS[kid].items() if not k.startswith("_")} +def _vector_resolver(vector, keys=KEYS): + """Resolve a vector's keys from the directories it lists, by thumbprint.""" - -def _vector_resolver(vector): async def resolve(keyid, *, signer_origin=None): ids = vector["directories"].get(signer_origin, {}).get("keys_ref", []) - return ResolvedSignerKey(signer_origin, _public(keyid)) if keyid in ids else None + public = {k: v for k, v in keys[keyid].items() if not k.startswith("_")} + return ResolvedSignerKey(signer_origin, public) if keyid in ids else None return resolve @@ -220,29 +218,6 @@ def test_profile_vector_signature_bases(path): assert base.decode() == expected -@pytest.mark.parametrize("path", GOVERNANCE_VECTORS, ids=lambda p: p.name) -async def test_governance_vectors(path): - from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer - - vector = json.loads(path.read_text()) - - async def verify(): - return await verify_wba_governance_issuer( - vector["jws"], - signer_keys=_vector_resolver(vector), - governance_agents=vector["role_listing"]["adcp:governance"], - now=vector["reference_now"], - ) - - expected = vector["expected_outcome"] - if expected["success"]: - assert await verify() == expected["issuer"] - return - with pytest.raises(WbaGovernanceError) as caught: - await verify() - assert caught.value.code == expected["error_code"] - - def test_signer_reproduces_independent_vector(): """Signing the committed base with the committed seed gives the committed signature.""" from adcp.signing._rfc9421 import parse_dictionary @@ -1102,80 +1077,3 @@ async def dispatch(scope, receive, send): # A current list, or a stale one for a non-spend call, proceeds. assert response.status_code == 200, response.text assert dispatched == [True] - - -def _governance_token(key, header_extra=None, claims_extra=None, *, iss=None): - from joserfc import jws as jose_jws - from joserfc.jwk import OKPKey - - from adcp.signing.wba import _governance_registry - - identity = iss or KEYS["1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE"]["identity"] - header = { - "alg": "EdDSA", - "typ": "adcp-gov+jws", - "jku": identity + "/.well-known/http-message-signatures-directory", - "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", - **(header_extra or {}), - } - claims = {"iss": identity, "aud": "https://seller-7k3q.com/mcp/", "iat": 1000, "exp": 1600} - claims.update(claims_extra or {}) - private = private_key_from_jwk( - KEYS["1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE"], d_field="_private_d_for_test_only" - ) - return jose_jws.serialize_compact( - header, - json.dumps(claims).encode(), - OKPKey.import_key(private), - registry=_governance_registry("EdDSA"), - ) - - -@pytest.mark.parametrize("markers", ["matched", "claim_without_marker", "task_without_hash"]) -async def test_governance_action_binding_markers(markers): - """Action-authorizing tokens carry crit markers (security.mdx checklist step 4).""" - from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer - - vector = json.loads((ROOT / "governance/001-governance-token.json").read_text()) - binding = {"authorized_task": "create_media_buy", "authorized_payload_hash": "h"} - header = {"crit": list(binding), **{name: True for name in binding}} - if markers == "claim_without_marker": - header = {} - claims = dict(binding) - if markers == "task_without_hash": - header = {"crit": ["authorized_task"], "authorized_task": True} - claims = {"authorized_task": "create_media_buy"} - token = _governance_token(None, header, claims) - - async def verify(): - return await verify_wba_governance_issuer( - token, - signer_keys=_vector_resolver(vector), - governance_agents=vector["role_listing"]["adcp:governance"], - now=1100, - ) - - if markers == "matched": - assert await verify() == "https://governance-7k3q.com" - return - with pytest.raises(WbaGovernanceError) as caught: - await verify() - assert caught.value.code == "governance_token_invalid" - - -async def test_governance_issuer_is_checked_before_key_discovery(): - from adcp.signing.wba import WbaGovernanceError, verify_wba_governance_issuer - - token = _governance_token(None, iss="https://buyer-7k3q.com") - - async def resolve(keyid, *, signer_origin=None): - pytest.fail("an unlisted issuer must not trigger key discovery") - - with pytest.raises(WbaGovernanceError) as caught: - await verify_wba_governance_issuer( - token, - signer_keys=resolve, - governance_agents=["https://governance-7k3q.com"], - now=1100, - ) - assert caught.value.code == "governance_issuer_not_authorized" diff --git a/tests/conformance/vectors/wba-profile/governance/001-governance-token.json b/tests/conformance/vectors/wba-governance/001-governance-token.json similarity index 100% rename from tests/conformance/vectors/wba-profile/governance/001-governance-token.json rename to tests/conformance/vectors/wba-governance/001-governance-token.json diff --git a/tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json b/tests/conformance/vectors/wba-governance/002-governance-token-key-not-in-issuer-directory.json similarity index 100% rename from tests/conformance/vectors/wba-profile/governance/002-governance-token-key-not-in-issuer-directory.json rename to tests/conformance/vectors/wba-governance/002-governance-token-key-not-in-issuer-directory.json diff --git a/tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json b/tests/conformance/vectors/wba-governance/003-governance-token-from-non-governance-agent.json similarity index 100% rename from tests/conformance/vectors/wba-profile/governance/003-governance-token-from-non-governance-agent.json rename to tests/conformance/vectors/wba-governance/003-governance-token-from-non-governance-agent.json diff --git a/tests/conformance/vectors/wba-governance/UPSTREAM.json b/tests/conformance/vectors/wba-governance/UPSTREAM.json new file mode 100644 index 000000000..0ec4fc080 --- /dev/null +++ b/tests/conformance/vectors/wba-governance/UPSTREAM.json @@ -0,0 +1,19 @@ +{ + "repository": "adcontextprotocol/adcp", + "pull_request": 7894, + "commit": "e24eeb090c4365707641099cbbb45f9945c6d6e0", + "path": "static/compliance/source/test-vectors/wba-profile/", + "vendored_on": "2026-10-16", + "renamed": { + "governance/001-governance-token.json": "001-governance-token.json", + "governance/002-governance-token-key-not-in-issuer-directory.json": "002-governance-token-key-not-in-issuer-directory.json", + "governance/003-governance-token-from-non-governance-agent.json": "003-governance-token-from-non-governance-agent.json" + }, + "$comment": "The Web Bot Auth profile vectors at adcp 49b3a315 no longer carry these governance vectors. They grade verify_wba_governance_issuer, an AdCP-layer check after WBA verification, until the R4 erratum publishes its own vectors.", + "sha256": { + "001-governance-token.json": "8e3a53bb9fa52b376d242ba67e0a86246af2fb97b44a9407212f65e506ed213b", + "002-governance-token-key-not-in-issuer-directory.json": "8559d3aab6a1db4f2e8afe6d81f36a6e2e54b262464d6179ee04d6846ec9d557", + "003-governance-token-from-non-governance-agent.json": "cbf328b7b7fe63df938da84ab86a94310f3ea9c232da99323cc32fbcee29e65b", + "keys.json": "08637bc288547315e6ce72e895d18ff5f3ab20a5f24375a15d0ebda43a993b3c" + } +} diff --git a/tests/conformance/vectors/wba-governance/keys.json b/tests/conformance/vectors/wba-governance/keys.json new file mode 100644 index 000000000..f66c718ab --- /dev/null +++ b/tests/conformance/vectors/wba-governance/keys.json @@ -0,0 +1,35 @@ +{ + "$comment": "Public test keys for the Web Bot Auth profile conformance vectors. The private seeds are published so that implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string 'rfc004-test/'. Web Bot Auth section 6.8: test keys MUST NOT be used in production, and verifiers SHOULD reject them when detected. Each kid is the RFC 7638 JWK thumbprint (RFC 8037 Appendix A.3 for Ed25519). Each entry's identity is the origin whose key directory publishes the key.", + "keys": [ + { + "identity": "https://buyer-7k3q.com", + "seed_name": "buyer-request", + "kty": "OKP", + "crv": "Ed25519", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", + "x": "yIxM3SIY89_Yhr6EA9hik-AJjWSDPraK5chDqQ3Xlf0", + "use": "sig", + "_private_d_for_test_only": "qgZV0LEd-ZDGdPdhM182B1YmHy0OAsNAEZT-feYyfbU" + }, + { + "identity": "https://agent.brand-7k3q.com", + "seed_name": "brand-request", + "kty": "OKP", + "crv": "Ed25519", + "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", + "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", + "use": "sig", + "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" + }, + { + "identity": "https://governance-7k3q.com", + "seed_name": "governance-document", + "kty": "OKP", + "crv": "Ed25519", + "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", + "x": "k-Q_ekTZQ_Hv6lLlIUYfE7jpJSCZ5nsLDfeoLIAkrL4", + "use": "sig", + "_private_d_for_test_only": "nfv0d9-TrrE1ajrAPEK-2yPoG9PHHbex-aqnv4xVnKQ" + } + ] +} diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json index 141e1afa1..50dc09a99 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM.json +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -8,9 +8,6 @@ "README.md": "UPSTREAM-README.md" }, "sha256": { - "governance/001-governance-token.json": "8e3a53bb9fa52b376d242ba67e0a86246af2fb97b44a9407212f65e506ed213b", - "governance/002-governance-token-key-not-in-issuer-directory.json": "8559d3aab6a1db4f2e8afe6d81f36a6e2e54b262464d6179ee04d6846ec9d557", - "governance/003-governance-token-from-non-governance-agent.json": "cbf328b7b7fe63df938da84ab86a94310f3ea9c232da99323cc32fbcee29e65b", "keys.json": "08637bc288547315e6ce72e895d18ff5f3ab20a5f24375a15d0ebda43a993b3c", "negative/001-missing-nonce.json": "12a60d6067c347976f2ec1769c2078c07f049f490a17c3da602898b4fdc8c3ad", "negative/002-replayed-nonce.json": "7471d84d3aca75a196439b80c9a605230601c5495464daf6da6d7f9baeed1971", From 3317d998ac4de5e87c547b595f2cfdc2abb7b351 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:51:52 -0400 Subject: [PATCH 19/46] test(signing): vendor the wba-profile vectors from the #7894 head Re-vendor static/compliance/source/test-vectors/wba-profile from adcontextprotocol/adcp at 49b3a315, the head of #7894. The request and directory vectors keep their signatures and change only their comments. The directory no longer carries governance vectors or the governance key, and it adds scenarios.json, the failure scenarios that the interop harness signs at run time. --- tests/conformance/signing/test_wba_profile.py | 2 +- .../conformance/vectors/wba-profile/README.md | 8 +- .../vectors/wba-profile/UPSTREAM-README.md | 61 +- .../vectors/wba-profile/UPSTREAM.json | 19 +- .../conformance/vectors/wba-profile/keys.json | 10 - .../negative/001-missing-nonce.json | 2 +- .../negative/002-replayed-nonce.json | 2 +- .../003-signature-agent-not-covered.json | 2 +- .../004-key-not-in-named-directory.json | 2 +- .../negative/006-two-signatures.json | 2 +- .../vectors/wba-profile/scenarios.json | 600 ++++++++++++++++++ 11 files changed, 650 insertions(+), 60 deletions(-) create mode 100644 tests/conformance/vectors/wba-profile/scenarios.json diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index f97c9a9d9..d64509a5b 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -59,7 +59,7 @@ async def resolve(keyid, *, signer_origin=None): def test_vendored_vectors_match_recorded_provenance(): upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) - assert upstream["commit"] == "e24eeb090c4365707641099cbbb45f9945c6d6e0" + assert upstream["commit"] == "49b3a3157e55a7ec37816908ec28a2e25057fd89" vendored = { str(path.relative_to(ROOT)) for path in ROOT.rglob("*") diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index d182feff9..e3e1dab1b 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,12 +1,14 @@ # Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`e24eeb090c4365707641099cbbb45f9945c6d6e0`, -`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-16. The +`49b3a3157e55a7ec37816908ec28a2e25057fd89`, +`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-18. The upstream `README.md` is vendored as `UPSTREAM-README.md`. `UPSTREAM.json` records each file's SHA-256, and `tests/conformance/signing/test_wba_profile.py` checks them. The specification is `docs/building/by-layer/L1/wba-profile.mdx` at the same commit (proposed for AdCP 3.3). The public private seeds in `keys.json` are -test inputs only. +test inputs only. `scenarios.json` holds the failure scenarios that the AdCP +interop harness (`tests/wba-interop`) signs at run time; this SDK's CI grades +them through `tests/wba_interop/adapter.py`. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md index 6aefc5dd8..fef711e78 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md +++ b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md @@ -4,40 +4,37 @@ Test vectors for the proposed AdCP profile of Web Bot Auth (WBA) for request and Specification: [Web Bot Auth request-signing profile](https://adcontextprotocol.org/docs/building/by-layer/L1/wba-profile) in `docs/building/by-layer/L1/wba-profile.mdx`. Section numbers in vector comments refer to [draft-ietf-webbotauth-httpsig-protocol-00](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/00/) unless they name RFC 9421. -**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/wba-profile/`, with `{version}` either an immutable release or `latest`. Tree preserved: `keys.json`, `positive/*.json`, `negative/*.json`, and `governance/*.json` are all resolvable. +**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/wba-profile/`, with `{version}` either an immutable release or `latest`. Tree preserved: `keys.json`, `scenarios.json`, `positive/*.json`, and `negative/*.json` are all resolvable. ## Test keys are public `keys.json` publishes the private seed of every test key in `_private_d_for_test_only`, so implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string `rfc004-test/`, used as the Ed25519 private key. Each `kid` is the key's JWK thumbprint (RFC 7638; RFC 8037 Appendix A.3 for Ed25519), and each entry's `identity` is the origin whose key directory publishes the key. -**These keys are valid only for grading against this suite.** WBA section 6.8: test keys "MUST NOT be used in production", and verifiers SHOULD reject them when detected. A production verifier that accepts a directory containing `6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I`, `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI`, or `1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE` is exploitable. +**These keys are valid only for grading against this suite.** WBA section 6.8: test keys "MUST NOT be used in production", and verifiers SHOULD reject them when detected. A production verifier that accepts a directory containing `6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I`, or `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI` is exploitable. -The domains in the vectors (`buyer-7k3q.com`, `agent.brand-7k3q.com`, `governance-7k3q.com`, `seller-7k3q.com`) are placeholders that resolve to nothing. A harness serves each directory from the vector's `directories` object instead of fetching it. +The domains in the vectors (`buyer-7k3q.com`, `agent.brand-7k3q.com`, `seller-7k3q.com`) are placeholders that resolve to nothing. A harness serves each directory from the vector's `directories` object instead of fetching it. ## Scope -The vectors exercise the profile rules: the covered `Signature-Agent` member, key selection by thumbprint from the directory the member names, the required `nonce`, replay rejection, one signature per request, the signed directory response, the `400`, `403`, and `429` responses, and the two checks on a governance token: its key is in the issuer's directory, and its issuer is the buyer's governance agent. They do not exercise live directory fetches, revocation-list polling, or onboarding state, which need live endpoints and belong in integration suites. +The vectors exercise the profile rules: the covered `Signature-Agent` member, key selection by thumbprint from the directory the member names, the required `nonce`, replay rejection, one signature per request, the signed directory response, and the `400`, `403`, and `429` responses. JWS artifacts, such as governance tokens, are outside the profile and have no vectors here. The vectors do not exercise live directory fetches, revocation-list polling, or onboarding state, which need live endpoints and belong in integration suites. ## File layout ``` test-vectors/wba-profile/ ├── README.md this file -├── keys.json three Ed25519 test keys, one per identity, with private seeds +├── keys.json two Ed25519 test keys, one per identity, with private seeds +├── scenarios.json failure scenarios as data, signed by a harness at run time ├── positive/ requests and responses that MUST verify │ ├── 001-single-signer-request.json buyer agent signs create_media_buy │ └── 002-signed-directory-response.json directory response signed per key (WBA Appendix B.1) -├── negative/ requests a verifier MUST reject, with the expected status -│ ├── 001-missing-nonce.json → 403 with Accept-Signature (nonce required on every signed request) -│ ├── 002-replayed-nonce.json → 429 with Accept-Signature carrying a fresh nonce (needs test_harness_state) -│ ├── 003-signature-agent-not-covered.json → 403 (Signature-Agent present but not a covered component) -│ ├── 004-key-not-in-named-directory.json → 403 (key is published by an origin other than the one named) -│ ├── 005-malformed-signature-agent.json → 400 (Signature-Agent does not parse) -│ └── 006-two-signatures.json → 403 (two web-bot-auth signatures on one request, each valid on its own) -└── governance/ governance tokens: one agent per purpose - ├── 001-governance-token.json governance agent, its own key → verifies - ├── 002-governance-token-key-not-in-issuer-directory.json → governance_key_unknown - └── 003-governance-token-from-non-governance-agent.json → governance_issuer_not_authorized +└── negative/ requests a verifier MUST reject, with the expected status + ├── 001-missing-nonce.json → 403 (nonce required on every signed request) + ├── 002-replayed-nonce.json → 429 (needs test_harness_state) + ├── 003-signature-agent-not-covered.json → 403 (Signature-Agent present but not a covered component) + ├── 004-key-not-in-named-directory.json → 403 (key is published by an origin other than the one named) + ├── 005-malformed-signature-agent.json → 400 (Signature-Agent does not parse) + └── 006-two-signatures.json → 403 (two web-bot-auth signatures on one request, each valid on its own) ``` ## Vector format @@ -98,21 +95,21 @@ Every request vector has this shape: - **`expected_outcome.success`**: `true` for positive vectors, `false` for negative. - **`expected_outcome.identities`**: positive vectors only. Keyed by the signature's label, the origin the verifier attributes the signature to. - **`expected_outcome.status`**: negative vectors only. The HTTP status the verifier answers: `400` when the signature headers do not parse, `403` when a signature or the profile's checks fail, and `429` when the signature or its nonce was used before. Conformance requires this status. -- **`expected_outcome.accept_signature`**: negative vectors with `403` or `429`. The `Accept-Signature` member the verifier sends (RFC 9421 section 5.1): the label, the covered components, and the parameters the profile requires. `"nonce": "verifier-chosen"` marks the replay case, where the verifier supplies a fresh nonce value that this vector does not pin. The exact serialization is RFC 9421 section 5.1; conformance grades the status, and a harness MAY check that `Accept-Signature` names at least these components and parameters. +- **`expected_outcome.accept_signature`**: negative vectors with `403` or `429`. The `Accept-Signature` member that the AdCP layer SHOULD send ([AdCP checks after Web Bot Auth verification](https://adcontextprotocol.org/docs/building/by-layer/L1/security#adcp-checks-after-web-bot-auth-verification); RFC 9421 section 5.1): the label, the covered components, and the parameters the profile requires. `"nonce": "verifier-chosen"` marks the replay case, where the verifier supplies a fresh nonce value that this vector does not pin. The exact serialization is RFC 9421 section 5.1; conformance grades the status, and a harness MAY check that `Accept-Signature` names at least these components and parameters. - **`expected_outcome.reason`**: the profile rule the request breaks. Informational. - **`$comment`**: free-form notes, including which non-conformant verifier behavior the vector catches. -### Governance vectors +### Failure scenarios -Each vector under `governance/` carries a compact JWS instead of a request: +`scenarios.json` holds failure scenarios as data instead of signed requests. A harness signs each scenario at run time with the reference library, so no committed signature expires, and grades the verifier on the scenario's expected outcome. The file has the following fields: -- **`document_type`**: `governance_context`. -- **`jws`**: the token as a verifier receives it. `decoded` repeats its header and payload for reading. -- **`directories`**: as for request vectors. The verifier resolves the key from the directory of the `iss` origin, which `jku` names. -- **`role_listing`**: the origins listed in each role by a record the verifier already trusts. In deployment, that record is the governance-typed `agents[]` entry in the buyer's `brand.json`, or the `adcp:governance` role in `trust.json` once #7809 lands. -- **`expected_outcome`**: `issuer` for the positive vector; for a negative vector, the `error_code` from the governance error table in `security.mdx`. - -The vectors grade the two governance checks. The two negative vectors each fail one check: `002` the key lookup in the `iss` origin's directory, and `003` the check that the issuer is the buyer's governance agent. The positive vector passes the remaining checks a verifier runs on the way (`alg`, `typ`, `jku` equal to the `iss` origin's directory, the signature, `exp` and `iat`), but no vector here fails them. The other checklist steps (`aud`, `phase`, `sub`, action binding, revocation, replay) are out of scope. +- **`signer`**: the identity and `kid` from `keys.json` that sign every scenario. +- **`requests`**, **`components`**, and **`parameters`**: the base requests (`post` and `get`), the components each one covers, and the default signature parameters. `{label}` in a component stands for the signature's label. `created` and `expires` are offsets in seconds from the harness's clock. A `nonce` object asks for that many random bytes in that encoding: `base64url` (unpadded), `base64` (canonical padded), or `base64url-padded`. A `base64url-padded` value contains at least one `-` or `_`; without one, it is also canonical padded base64, which the profile accepts. +- **`request_scenarios`**: each entry names a `base` request and overrides any of `label`, `components`, `parameters` (a `null` value omits the parameter), and `signature_agent` (the header value). `extra_signature` adds a second signature with its own label, key, components, and parameters. `after_signing` changes `headers`, appends `append_to_body` to the body, or appends raw `append_members` to `Signature-Input` and `Signature` after signing. `directories` lists the `kid` values each origin serves; it defaults to the signer's own directory. +- **`directory_scenarios`**: each entry overrides how the signer's directory response is signed: `keys` (the kids that sign), `unsigned_keys` (public keys in the body that do not sign), `authority`, `parameters`, `components`, `content_type`, and `after_signing`. +- **`expected`**: `success` and `identity` for a scenario that verifies, or `success: false` and the `status` for a request scenario that a verifier rejects. A directory scenario that a verifier rejects has no status of its own: the request that needed its key gets `403`. +- **`tier`**: `wba` when the reference library alone, with no AdCP rules, gives the expected outcome; `adcp` when the bare library gives the opposite outcome and only the profile's rules give the expected one. A WBA MUST that the library does not enforce, such as an `https` origin (`R16`), is `adcp` until the library enforces it. A harness checks every tier against the bare library on every run. +- **`basis`**: the rule the scenario grades. Informational. ## Conformance expectations @@ -120,11 +117,11 @@ An implementation is conformant when, for every vector: 1. **Positive request vectors** verify the `web-bot-auth` signature and attribute it to the origin in `expected_outcome.identities`. 2. **The directory-response vector** verifies the response signature with the key the directory body provides and matches `Content-Digest` against the body. -3. **Negative vectors** answer `expected_outcome.status`. A `403` carries `Accept-Signature`. -4. **Governance vectors** verify `001` and attribute it to `expected_outcome.issuer`, and reject `002` and `003` with `expected_outcome.error_code`. +3. **Negative vectors** answer `expected_outcome.status`. `Accept-Signature` is an AdCP-layer SHOULD, and conformance does not grade it. +4. **Failure scenarios** answer `expected` for every entry in `scenarios.json`. 5. **Signature bytes on positive vectors** match the committed `Signature` values byte for byte when the implementation signs the committed `expected_signature_base` with the corresponding private seed. Ed25519 is deterministic. -Several negative vectors carry cryptographically valid signatures on purpose. `001-missing-nonce`, `003-signature-agent-not-covered`, and `006-two-signatures` verify once the targeted check is removed, and `004-key-not-in-named-directory` verifies under a verifier that looks keys up by thumbprint alone. Both governance negatives carry valid signatures too: `002` verifies under a thumbprint-only lookup, and `003` verifies when the governance-agent check is skipped. A verifier that accepts any of them has skipped a profile rule, not failed at cryptography. +Several negative vectors carry cryptographically valid signatures on purpose. `001-missing-nonce`, `003-signature-agent-not-covered`, and `006-two-signatures` verify once the targeted check is removed, and `004-key-not-in-named-directory` verifies under a verifier that looks keys up by thumbprint alone. A verifier that accepts any of them has skipped a profile rule, not failed at cryptography. ## Running vectors against an implementation @@ -134,13 +131,13 @@ Several negative vectors carry cryptographically valid signatures on purpose. `0 4. Build the request from `request`, and invoke verification with `reference_now` as the wall clock. 5. Assert on `expected_outcome`: identities for positive vectors, status for negative vectors. -Run the positive vectors first. If `positive/001` fails, the signature base, key loading, or thumbprint computation is wrong; the `expected_signature_base` field isolates the first of those. Then run `005` (parse), `001`, `003`, and `006` (profile rules without state), `004` (key lookup), and `002` (replay state). Run the governance vectors last; `003` is the one that shows a token is accepted for its issuer, not for its key. +Run the positive vectors first. If `positive/001` fails, the signature base, key loading, or thumbprint computation is wrong; the `expected_signature_base` field isolates the first of those. Then run `005` (parse), `001`, `003`, and `006` (profile rules without state), `004` (key lookup), and `002` (replay state). ## Generating the vectors `scripts/generate-wba-profile-vectors.mjs` writes every file in this directory except this README. It derives the keys from their seeds, builds each signature base component by component, and signs it with Ed25519. Run `node scripts/generate-wba-profile-vectors.mjs --check` to confirm the committed files match the generator. The positive values are the ones in RFC #7878's illustrations. Do not hand-edit the vector files; change the generator and run it again. -`tests/wba-profile-vectors.test.cjs` verifies the vectors without sharing code with the generator. It parses the headers, rebuilds each signature base from the request, and checks signatures and digests with `node:crypto`. It verifies the governance tokens with a minimal compact-JWS verifier, one named check per stage. For each negative vector it asserts the expected status and the check that fails. For each negative vector with a valid signature, it asserts that the request verifies once that one check is skipped. `npm run test:wba-profile-vectors` runs both. The positive vectors were also verified with a generic RFC 9421 library ([`http-message-sig`](https://www.npmjs.com/package/http-message-sig)) and, for the single-signer request, with Cloudflare's [`web-bot-auth`](https://www.npmjs.com/package/web-bot-auth) library. +`tests/wba-profile-vectors.test.cjs` verifies the vectors without sharing code with the generator. It parses the headers, rebuilds each signature base from the request, and checks signatures and digests with `node:crypto`. For each negative vector it asserts the expected status and the check that fails. For each negative vector with a valid signature, it asserts that the request verifies once that one check is skipped. `npm run test:wba-profile-vectors` runs both. The positive vectors were also verified with a generic RFC 9421 library ([`http-message-sig`](https://www.npmjs.com/package/http-message-sig)) and, for the single-signer request, with Cloudflare's [`web-bot-auth`](https://www.npmjs.com/package/web-bot-auth) library. ## Adding vectors @@ -151,4 +148,4 @@ Every added vector MUST: 3. State `expected_outcome.status` for a negative vector in the profile's status-code vocabulary, and `accept_signature` for a `403`. 4. Include `expected_signature_base` for every label when the signature headers parse, computed from the request as sent. 5. Include `test_harness_state` when the vector needs preloaded verifier state. -6. Come from `scripts/generate-wba-profile-vectors.mjs`. A negative vector also needs an entry in `TARGETS` (or `GOVERNANCE_TARGETS` for a governance token) in `tests/wba-profile-vectors.test.cjs`, naming the check it targets. +6. Come from `scripts/generate-wba-profile-vectors.mjs`. A negative vector also needs an entry in `TARGETS` in `tests/wba-profile-vectors.test.cjs`, naming the check it targets. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json index 50dc09a99..af7294702 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM.json +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -1,22 +1,23 @@ { "repository": "adcontextprotocol/adcp", "pull_request": 7894, - "commit": "e24eeb090c4365707641099cbbb45f9945c6d6e0", + "commit": "49b3a3157e55a7ec37816908ec28a2e25057fd89", "path": "static/compliance/source/test-vectors/wba-profile/", - "vendored_on": "2026-10-16", + "vendored_on": "2026-10-18", "renamed": { "README.md": "UPSTREAM-README.md" }, "sha256": { - "keys.json": "08637bc288547315e6ce72e895d18ff5f3ab20a5f24375a15d0ebda43a993b3c", - "negative/001-missing-nonce.json": "12a60d6067c347976f2ec1769c2078c07f049f490a17c3da602898b4fdc8c3ad", - "negative/002-replayed-nonce.json": "7471d84d3aca75a196439b80c9a605230601c5495464daf6da6d7f9baeed1971", - "negative/003-signature-agent-not-covered.json": "fd2203fe8e445344c4fa27331d119216f6fd8383e8cfd0cb0c85b1dcd29019fc", - "negative/004-key-not-in-named-directory.json": "366094bc9af941dd20127402c8ebf03ab6eff1094dc54363fabec759b75ceb67", + "UPSTREAM-README.md": "1e1442e6d27e0f88cd9dc70d9942e1f30bb658c19bde3d53067ee8e64c6d4bad", + "keys.json": "257850b14fe217f67596808040e2c7b4f62bd36bf2c25d0712d066733008b100", + "negative/001-missing-nonce.json": "79a9de2bc336a3b12b20e8cb026614646dc84dbbf05d897c6c34808682062acf", + "negative/002-replayed-nonce.json": "51d2ce5c81d8a8e60b99d5a7eaef80f18baba2a104de93d1fc8aeac5c7aaa7be", + "negative/003-signature-agent-not-covered.json": "ec58e8615d3f933e5693ceb310ef50f35d2a39356b238c5cdec28377b72548c8", + "negative/004-key-not-in-named-directory.json": "1d830742a0cc8f3e556abb7734b093c66f81582a7138b639fae29f358e44371b", "negative/005-malformed-signature-agent.json": "c0bf6e2d95be5f47d18a26abbcf0c38b2fb8a229a77d4acc4cad76fecad61f59", - "negative/006-two-signatures.json": "6dc88d4db2205ba7f0611fd9d4e2e6d7106ed45ea9c8238c5a801abb660901a1", + "negative/006-two-signatures.json": "87cb93e1f9bc4fba2eadb0df7832d51c9edf2d0add02aab5cee8265b9d7f1661", "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", - "UPSTREAM-README.md": "0dca00383bd0907bbf0fadfe09fb9787fd7cdfeabbcdc23b52c17038e1976bfd" + "scenarios.json": "858737d50bd24da316f628cd05e70e8f73e3103414009c59b135151a356b5903" } } diff --git a/tests/conformance/vectors/wba-profile/keys.json b/tests/conformance/vectors/wba-profile/keys.json index f66c718ab..2a6750ab3 100644 --- a/tests/conformance/vectors/wba-profile/keys.json +++ b/tests/conformance/vectors/wba-profile/keys.json @@ -20,16 +20,6 @@ "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", "use": "sig", "_private_d_for_test_only": "mu0nHxQDWktUz0o16gWmACuOZ86HpAwfmO4iP1qApp8" - }, - { - "identity": "https://governance-7k3q.com", - "seed_name": "governance-document", - "kty": "OKP", - "crv": "Ed25519", - "kid": "1XlJ3EIte7bPOEqKQtKrKNTNy5hywTyrwFXWrMbXKqE", - "x": "k-Q_ekTZQ_Hv6lLlIUYfE7jpJSCZ5nsLDfeoLIAkrL4", - "use": "sig", - "_private_d_for_test_only": "nfv0d9-TrrE1ajrAPEK-2yPoG9PHHbex-aqnv4xVnKQ" } ] } diff --git a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json index eaeb79b53..0196f05d2 100644 --- a/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/001-missing-nonce.json @@ -43,5 +43,5 @@ }, "reason": "The profile requires an RFC 9421 nonce on every signed request. Without one the verifier cannot detect replay." }, - "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403 with Accept-Signature listing nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature is cryptographically valid over its covered components, so a verifier that does not enforce the nonce requirement accepts it. The verifier answers 403, and the Accept-Signature hint lists nonce among the required parameters. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json index c1887bbe1..6c8d4aba2 100644 --- a/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json +++ b/tests/conformance/vectors/wba-profile/negative/002-replayed-nonce.json @@ -54,5 +54,5 @@ }, "reason": "The verifier MUST reject a nonce it has already seen for the same identity within the signature's lifetime." }, - "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The 429 carries Accept-Signature with a verifier-chosen nonce value (Web Bot Auth section 5.3; RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The request is byte-identical to positive/001-single-signer-request.json. The verifier checks the nonce per signature and per identity, whether or not an edge verifier ran first. The Accept-Signature hint on the 429 carries a verifier-chosen nonce value (Web Bot Auth section 5.3; RFC 9421 sections 5.1 and 7.2.2), which the signer uses on its next attempt; the value is not pinned by this vector. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json index 7465d9db7..0e2bf8d2c 100644 --- a/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json +++ b/tests/conformance/vectors/wba-profile/negative/003-signature-agent-not-covered.json @@ -43,5 +43,5 @@ }, "reason": "Web Bot Auth section 5.2.1: the Signature-Agent member keyed to the signature label MUST be signed as a component. An uncovered member is an unverified claim and MUST NOT be attributed (section 4.1)." }, - "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature verifies over the components it does cover, so a verifier that reads the identity from the uncovered header and skips the coverage check accepts it and attributes the request to an origin nobody signed for. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json index ec522dfa5..9682be0ca 100644 --- a/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json +++ b/tests/conformance/vectors/wba-profile/negative/004-key-not-in-named-directory.json @@ -48,5 +48,5 @@ }, "reason": "Key lookup is keyed on the (origin, keyid) pair, never on keyid alone (Web Bot Auth section 5.4). The directory at the named origin holds no key with this thumbprint, so the request is unverified and MUST NOT be attributed to that origin (section 6.10)." }, - "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The signature is valid under the buyer agent's key, and the directories object lists that key under its own origin so the harness can show the trap: a verifier that indexes keys by thumbprint alone finds the key, verifies, and attributes the request to the brand agent, an origin that never published the key. A conformant verifier resolves only https://agent.brand-7k3q.com/.well-known/http-message-signatures-directory and rejects. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json index dd02f37fe..7c694cd02 100644 --- a/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json +++ b/tests/conformance/vectors/wba-profile/negative/006-two-signatures.json @@ -49,5 +49,5 @@ }, "reason": "The profile takes exactly one web-bot-auth signature and one Signature-Agent member per request (RFC 9421 section 3.2.1). Web Bot Auth section 5.2.2 permits more than one signature; the profile does not accept one." }, - "$comment": "The sig1 label is byte-identical to positive/001-single-signer-request.json, and the sig2 label is a second valid signature over the same request from another identity. Each verifies on its own against its own directory, so a verifier that loops over every label and accepts when each verifies has skipped the count check. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). A 403 or 429 carries Accept-Signature naming the components and parameters the profile requires, so the signer can sign again. Conformance grades the status code; the accept_signature object describes the header the verifier sends, and the exact serialization is RFC 9421 section 5.1." + "$comment": "The sig1 label is byte-identical to positive/001-single-signer-request.json, and the sig2 label is a second valid signature over the same request from another identity. Each verifies on its own against its own directory, so a verifier that loops over every label and accepts when each verifies has skipped the count check. The profile reports signature failures with HTTP status codes (Web Bot Auth sections 5.3 and 5.4). Conformance grades the status code. On a 403 or 429, the AdCP layer SHOULD add Accept-Signature naming the components and parameters the profile requires, so the signer can sign again (security.mdx, AdCP checks after Web Bot Auth verification); the accept_signature object describes that header, and the exact serialization is RFC 9421 section 5.1." } diff --git a/tests/conformance/vectors/wba-profile/scenarios.json b/tests/conformance/vectors/wba-profile/scenarios.json new file mode 100644 index 000000000..dff46e30f --- /dev/null +++ b/tests/conformance/vectors/wba-profile/scenarios.json @@ -0,0 +1,600 @@ +{ + "$comment": "Failure scenarios for the Web Bot Auth profile, as data. A harness signs each request and directory scenario at run time with the reference library (http-message-sig signs any component and parameter set), applies after_signing, and grades the verifier on expected. created and expires are offsets in seconds from the harness clock; a null parameter is omitted; a nonce object asks for that many random bytes in that encoding. tier \"wba\": the reference library with no AdCP policy already gives the expected outcome. tier \"adcp\": the bare library gives the opposite outcome and only the profile rules give the expected one; a WBA MUST the library does not enforce stays \"adcp\" until it does. See README.md.", + "spec_reference": "docs/building/by-layer/L1/wba-profile.mdx#profile-rules", + "signing_profile": "web-bot-auth", + "signer": { + "identity": "https://buyer-7k3q.com", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + }, + "requests": { + "post": { + "method": "POST", + "url": "https://seller-7k3q.com/mcp/", + "headers": { + "content-type": "application/json" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"create_media_buy\",\"arguments\":{\"idempotency_key\":\"4f6c1a0e-8d2b-4b7e-9c3a-2e5d7f1b0a96\",\"account\":{\"account_id\":\"acc_42\"},\"proposal_id\":\"prop_7\",\"total_budget\":{\"amount\":25000,\"currency\":\"USD\"}}}}" + }, + "get": { + "method": "GET", + "url": "https://seller-7k3q.com/mcp/tasks?id=a%7Eb&x=1", + "headers": {}, + "body": "" + } + }, + "components": { + "post": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent;key={label}" + ], + "get": [ + "@method", + "@target-uri", + "@authority", + "signature-agent;key={label}" + ] + }, + "parameters": { + "created": -5, + "expires": 295, + "nonce": { + "bytes": 64, + "encoding": "base64url" + }, + "tag": "web-bot-auth" + }, + "request_scenarios": [ + { + "id": "R00", + "name": "Baseline", + "base": "post", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "Profile rules" + }, + { + "id": "R01", + "name": "Body changed after signing", + "base": "post", + "after_signing": { + "append_to_body": "\n" + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Content-Digest matches the body: RFC 9530; WBA section 5.2" + }, + { + "id": "R02", + "name": "Covered header changed after signing", + "base": "post", + "after_signing": { + "headers": { + "content-type": "text/plain" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "RFC 9421 section 3.2" + }, + { + "id": "R03", + "name": "Expired", + "base": "post", + "parameters": { + "created": -400, + "expires": -100 + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "RFC 9421 section 3.2.1" + }, + { + "id": "R04", + "name": "created 120 seconds in the future", + "base": "post", + "parameters": { + "created": 120, + "expires": 400 + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: 60 seconds of clock skew" + }, + { + "id": "R05", + "name": "created 30 seconds in the future", + "base": "post", + "parameters": { + "created": 30, + "expires": 300 + }, + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "adcp", + "basis": "Profile: 60 seconds of clock skew; the library allows none by default" + }, + { + "id": "R06", + "name": "Lifetime of 3600 seconds", + "base": "post", + "parameters": { + "created": -5, + "expires": 3595 + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: expires at most 300 seconds after created" + }, + { + "id": "R07", + "name": "Nonce of 32 bytes", + "base": "post", + "parameters": { + "nonce": { + "bytes": 32, + "encoding": "base64url" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: 64-byte nonce; validateNonce" + }, + { + "id": "R08", + "name": "Nonce in canonical padded base64", + "base": "post", + "parameters": { + "nonce": { + "bytes": 64, + "encoding": "base64" + } + }, + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "Profile: unpadded base64url or canonical padded base64" + }, + { + "id": "R09", + "name": "Nonce in padded base64url", + "base": "post", + "parameters": { + "nonce": { + "bytes": 64, + "encoding": "base64url-padded" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: unpadded base64url or canonical padded base64" + }, + { + "id": "R10", + "name": "No alg parameter", + "base": "post", + "parameters": { + "alg": null + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: alg required" + }, + { + "id": "R11", + "name": "alg other than ed25519", + "base": "post", + "parameters": { + "alg": "ecdsa-p256-sha256" + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: alg is ed25519 and matches the key; RFC 9421 section 3.2" + }, + { + "id": "R12", + "name": "GET covering only @authority and the Signature-Agent member", + "base": "get", + "components": [ + "@authority", + "signature-agent;key=sig1" + ], + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: cover @method, @target-uri, and @authority; WBA section 5.2 needs one of @authority or @target-uri" + }, + { + "id": "R13", + "name": "Body without content-digest covered", + "base": "post", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "signature-agent;key=sig1" + ], + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: a request with a body covers content-type and content-digest; WBA section 5.2" + }, + { + "id": "R14", + "name": "tag other than web-bot-auth", + "base": "post", + "parameters": { + "tag": "adcp/request-signing/v1" + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "WBA section 5.2: tag MUST be web-bot-auth" + }, + { + "id": "R15", + "name": "Bare-string Signature-Agent", + "base": "post", + "signature_agent": "\"https://buyer-7k3q.com\"", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent" + ], + "expected": { + "success": false, + "status": 400 + }, + "tier": "adcp", + "basis": "Profile: dictionary form only, so the field does not parse; WBA section 5.2.1 lets a verifier accept the bare string" + }, + { + "id": "R16", + "name": "http:// Signature-Agent", + "base": "post", + "signature_agent": "sig1=\"http://buyer-7k3q.com\"", + "directories": { + "http://buyer-7k3q.com": [ + "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I" + ] + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "WBA section 5.2.1: the origin MUST be https; the library accepts http (cloudflare/web-bot-auth#162)" + }, + { + "id": "R17", + "name": "Signature-Agent with a path", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com/agents/a\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "WBA section 5.5: a directory member MUST be an origin; the library does not check it" + }, + { + "id": "R17c", + "name": "Signature-Agent origin with a trailing /", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com/\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: the value is exactly the serialized origin (RFC 6454 section 6.2), compared without normalizing" + }, + { + "id": "R17d", + "name": "Signature-Agent origin with an uppercase host", + "base": "post", + "signature_agent": "sig1=\"https://BUYER-7k3q.com\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: the value is exactly the serialized origin, with a lowercase host, compared without normalizing" + }, + { + "id": "R17e", + "name": "Signature-Agent origin with an explicit :443", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com:443\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: the value is exactly the serialized origin, with no default port, compared without normalizing" + }, + { + "id": "R18", + "name": "Signature-Agent member with type=jwks_uri", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com/jwks.json\";type=jwks_uri", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: only type=directory; WBA section 5.2.1 lets a verifier ignore other types" + }, + { + "id": "R19", + "name": "Signature-Agent member keyed differently from the label", + "base": "post", + "signature_agent": "agent1=\"https://buyer-7k3q.com\"", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent;key=agent1" + ], + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "WBA section 5.2.1: the member keyed to the label; the library does not check it" + }, + { + "id": "R20", + "name": "Second signature with tag cdn-edge", + "base": "post", + "extra_signature": { + "label": "cdn", + "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", + "components": [ + "@method", + "@authority" + ], + "parameters": { + "created": -5, + "expires": 295, + "nonce": null, + "tag": "cdn-edge" + } + }, + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "adcp", + "basis": "Profile: Signature-Input members with other tags are not read; the library requires a label when a request carries two signatures" + }, + { + "id": "R20b", + "name": "Second member with tag cdn-edge whose Signature value is a token", + "base": "post", + "after_signing": { + "append_members": { + "signature-input": "cdn=(\"@method\" \"@authority\");tag=\"cdn-edge\"", + "signature": "cdn=edge-token" + } + }, + "expected": { + "success": false, + "status": 400 + }, + "tier": "wba", + "basis": "RFC 9421 section 4.2: every Signature member value is a Byte Sequence, so the field is malformed whatever the member's tag; members with other tags are not read, but the field is still parsed" + }, + { + "id": "R21", + "name": "Two Signature-Agent members, one signature", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\", sig2=\"https://agent.brand-7k3q.com\"", + "expected": { + "success": false, + "status": 403 + }, + "tier": "adcp", + "basis": "Profile: exactly one Signature-Agent member" + }, + { + "id": "R22", + "name": "Label other than sig1", + "base": "post", + "label": "wba", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "RFC 9421: the signer chooses the label" + }, + { + "id": "R23", + "name": "Components in another order", + "base": "post", + "components": [ + "signature-agent;key=sig1", + "@authority", + "content-digest", + "@target-uri", + "content-type", + "@method" + ], + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "RFC 9421: the order is the signer's" + } + ], + "directory_scenarios": [ + { + "id": "D00", + "name": "Baseline", + "expected": { + "success": true + }, + "tier": "wba", + "basis": "WBA Appendix B.1" + }, + { + "id": "D01", + "name": "Signed for another authority", + "authority": "agent.brand-7k3q.com", + "expected": { + "success": false + }, + "tier": "wba", + "basis": "WBA Appendix B.1: covers \"@authority\";req" + }, + { + "id": "D02", + "name": "Body changed after signing", + "after_signing": { + "append_to_body": "\n" + }, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA Appendix B.1: the verifier MUST validate Content-Digest; the library does not compare it with the body" + }, + { + "id": "D03", + "name": "Unsigned", + "keys": [], + "unsigned_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", + "x": "yIxM3SIY89_Yhr6EA9hik-AJjWSDPraK5chDqQ3Xlf0", + "use": "sig" + } + ], + "expected": { + "success": false + }, + "tier": "wba", + "basis": "Profile: every Ed25519 key signs (Appendix B.1 recommends it)" + }, + { + "id": "D04", + "name": "Signature created in the future", + "parameters": { + "created": 3600, + "expires": 90000 + }, + "expected": { + "success": false + }, + "tier": "wba", + "basis": "WBA Appendix B.1: reject a future created" + }, + { + "id": "D05", + "name": "Covers \"@authority\";req only", + "components": [ + "@authority;req" + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA Appendix B.1: covers content-digest" + }, + { + "id": "D06", + "name": "Expired signature", + "parameters": { + "created": -7200, + "expires": -3600 + }, + "expected": { + "success": false + }, + "tier": "wba", + "basis": "RFC 9421 section 3.2.1" + }, + { + "id": "D07", + "name": "Extra P-256 key that does not sign", + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig" + } + ], + "expected": { + "success": true + }, + "tier": "wba", + "basis": "Profile: ignore keys the profile does not use (RFC 7517 section 5)" + }, + { + "id": "D08", + "name": "Media type application/json", + "content_type": "application/json", + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA section 5.5.1: the well-known directory MUST be served with application/http-message-signatures-directory+json" + } + ] +} From e6825028f25a909e6c95756d3588ba2d2b9fd910 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:23:34 -0400 Subject: [PATCH 20/46] docs(signing): name the pyauth issue behind the async signature-base hook ProfileSigner reaches HTTPMessageSigner's protected base builder because the library has no public way to get a signature base before signing. pyauth/http-message-signatures#31 asks for one, so the module and the class docstrings name it as the condition for dropping the hook. --- src/adcp/signing/_rfc9421.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index 2bb327a7a..b8378a5e6 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -28,7 +28,8 @@ The signer subclass reaches ``_build_signature_base`` only for the async (:class:`~adcp.signing.SigningProvider`) path, where the base must exist -before an awaited signature. ``tests/conformance/signing/test_rfc9421_library_contract.py`` +before an awaited signature; pyauth/http-message-signatures#31 asks for a +public call. ``tests/conformance/signing/test_rfc9421_library_contract.py`` pins the library shapes this module relies on. """ @@ -447,7 +448,8 @@ class ProfileSigner(HTTPMessageSigner): (:class:`~adcp.signing.SigningProvider`) path awaits the signature, so it needs the base first, and the public ``build_signature_base`` helper returns one. Both reuse the library's own base builder through its - protected subclass hooks; the contract test pins them. + protected subclass hooks; the contract test pins them. Drop the hooks + once a release carries pyauth/http-message-signatures#31. """ def base( From 973bd55ee471d4a903758e3fe7bbbeb25a2c10dc Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 00:54:57 -0400 Subject: [PATCH 21/46] refactor(governance): split and parse governance tokens with the shared helpers The governance verifier had its own compact JWS splitter, its own strict JSON parser, and two checks that decoded base64url and re-encoded it to refuse a non-canonical spelling. Split the token with jws.parse_compact_jws and parse each segment with parse_strict_json, the splitter and parser that the revocation-list and Web Bot Auth paths use. b64url_decode is strict, so the re-encode checks go too. Outcomes and error codes are unchanged, with two exceptions where the old parser was weaker than every other JWS path in the SDK: a segment with an unpaired surrogate escape is refused (governance_token_invalid), and nesting deep enough to raise RecursionError is governance_token_invalid instead of an uncaught exception. Some failure messages change, because the splitter refuses a malformed header before the JSON step. The governance authorization tests (tests/test_governance_authorization.py and tests/conformance/vectors/governance-authorization.json) gate it. --- src/adcp/governance.py | 66 ++++++++---------------------------------- 1 file changed, 12 insertions(+), 54 deletions(-) diff --git a/src/adcp/governance.py b/src/adcp/governance.py index ef2e4e750..ee848ab28 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -33,12 +33,13 @@ from pydantic import BaseModel from adcp._version import resolve_adcp_version +from adcp.signing._strict_json import parse_strict_json from adcp.signing.brand_jwks import BrandJsonResolverError, _pick_agent from adcp.signing.canonical import canonicalize_target_uri from adcp.signing.crypto import b64url_decode, b64url_encode, import_jwk from adcp.signing.etld import host_from from adcp.signing.jwks import AsyncCachingJwksResolver, AsyncJwksResolver, JwksResolver -from adcp.signing.jws import _AdcpJwsRegistry +from adcp.signing.jws import JwsMalformedError, _AdcpJwsRegistry, parse_compact_jws from adcp.signing.replay import ReplayStore, supports_atomic_claim from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest @@ -65,7 +66,6 @@ ] GovernanceReplayResult = Literal["ok", "conflict", "replayed", "rate_abuse"] -_COMPACT_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") _CURRENCY_RE = re.compile(r"^[A-Z]{3}$") @@ -941,12 +941,10 @@ def read_governance_authorization_issuer(token: object) -> str | None: """ if not isinstance(token, str) or not token or len(token) > GOVERNANCE_MAX_TOKEN_BYTES: return None - parts = _compact_segments(token) - if parts is None: - return None try: - claims = _decode_json_segment(parts[1]) - except (TypeError, ValueError): + _header, payload, _signature = parse_compact_jws(token) + claims = _decode_json_segment(payload) + except (JwsMalformedError, TypeError, ValueError): return None issuer = claims.get("iss") if not isinstance(issuer, str): @@ -1135,15 +1133,13 @@ def reject( return reject("governance_token_invalid", str(exc)) if not isinstance(token, str) or not token or len(token) > GOVERNANCE_MAX_TOKEN_BYTES: return reject("governance_token_invalid", "governance_context is missing or too large") - parts = _compact_segments(token) - if parts is None: + try: + b64_header, b64_payload, _signature = parse_compact_jws(token) + except JwsMalformedError: return reject("governance_token_invalid", "governance_context is not a compact JWS") try: - header = _decode_json_segment(parts[0]) - claims = _decode_json_segment(parts[1]) - signature = b64url_decode(parts[2]) - if b64url_encode(signature) != parts[2]: - raise ValueError("non-canonical signature base64url") + header = _decode_json_segment(b64_header) + claims = _decode_json_segment(b64_payload) except (TypeError, ValueError): return reject("governance_token_invalid", "governance_context contains invalid JSON") alg = header.get("alg") @@ -1477,26 +1473,6 @@ def _public_jwk(jwk: Mapping[str, Any]) -> dict[str, Any]: return {name: jwk[name] for name in ("kty", "crv", "x", "y") if name in jwk} -def _compact_segments(token: str) -> tuple[str, str, str] | None: - """Split a compact JWS with ``joserfc.jws.extract_compact``, or ``None``.""" - if token.count(".") != 2 or any( - _COMPACT_SEGMENT_RE.fullmatch(part) is None for part in token.split(".") - ): - return None - try: - compact = jws.extract_compact( - token.encode("ascii"), registry=_AdcpJwsRegistry(strict_check_header=False) - ) - except (JoseError, ValueError): - return None - segments = compact.segments - return ( - segments["header"].decode("ascii"), - segments["payload"].decode("ascii"), - segments["signature"].decode("ascii"), - ) - - def _compact_json(value: object) -> bytes: return json.dumps( value, @@ -1508,26 +1484,8 @@ def _compact_json(value: object) -> bytes: def _decode_json_segment(segment: str) -> dict[str, Any]: - raw = b64url_decode(segment) - if b64url_encode(raw) != segment: - raise ValueError("non-canonical base64url") - - def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise ValueError("duplicate JSON object member") - result[key] = value - return result - - def reject_constant(value: str) -> None: - raise ValueError(f"non-finite JSON number: {value}") - - value = json.loads( - raw.decode("utf-8"), - object_pairs_hook=unique_object, - parse_constant=reject_constant, - ) + """A strict JSON object from a base64url JWS segment (``ValueError`` otherwise).""" + value = parse_strict_json(b64url_decode(segment)) if not isinstance(value, dict): raise ValueError("JWS segment is not a JSON object") return value From cc005c202a9a4423008efd13890baa83ffc7ef7c Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 01:52:30 -0400 Subject: [PATCH 22/46] refactor(governance): check crit markers once, with joserfc The governance verifier and the Web Bot Auth governance issuer check each carried a copy of security.mdx checklist step 4, and each checked the crit syntax by hand. Add governance.critical_claims_failure and call it from both. It hands the crit list and the markers to joserfc's JWSRegistry.check_header, which refuses a crit that isn't a list of registered names and a marker that is missing or isn't a boolean. The helper keeps the rules joserfc leaves open: no repeated entry, a claim present exactly when its marker is true, and authorized_task with authorized_payload_hash. The 3.2 verifier still requires crit, and the WBA check still treats an absent crit as empty. Behavior change in the 3.2 governance path, and in the WBA issuer check: a crit entry whose marker is absent, or isn't a boolean, is now refused (governance_token_invalid) even when its claim is absent too. RFC 7515 section 4.1.11 forbids a crit name that doesn't occur in the header, so refusing it is correct. Every other outcome is unchanged. The governance authorization tests (tests/test_governance_authorization.py and tests/conformance/vectors/governance-authorization.json) and tests/conformance/signing/test_wba_governance.py gate it. --- src/adcp/governance.py | 55 +++++++++++++++++--------- src/adcp/signing/wba.py | 30 +++----------- tests/test_governance_authorization.py | 27 +++++++++++++ 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/src/adcp/governance.py b/src/adcp/governance.py index ee848ab28..2ed983e32 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -1145,26 +1145,11 @@ def reject( alg = header.get("alg") if alg not in {"EdDSA", "ES256"} or header.get("typ") != GOVERNANCE_AUTHORIZATION_TYP: return reject("governance_token_invalid", "unsupported governance JWS alg or typ") - critical = header.get("crit") - if ( - not isinstance(critical, list) - or any(not isinstance(name, str) for name in critical) - or len(set(critical)) != len(critical) - or any(name not in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS for name in critical) - ): + if "crit" not in header: return reject("governance_token_invalid", "invalid governance JWS crit header") - for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: - has_claim = name in claims - has_marker = name in critical and header.get(name) is True - if has_claim != has_marker: - return reject( - "governance_token_invalid", f"{name} claim and critical marker must match" - ) - if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): - return reject( - "governance_token_invalid", - "authorized_task and authorized_payload_hash must appear together", - ) + critical_failure = critical_claims_failure(header, claims) + if critical_failure is not None: + return reject("governance_token_invalid", critical_failure) try: expected_issuer = canonicalize_target_uri(expected_issuer) issuer = claims.get("iss") @@ -1439,6 +1424,38 @@ def _finite_number(value: object) -> bool: ) +def critical_claims_failure(header: Mapping[str, Any], claims: Mapping[str, Any]) -> str | None: + """Check each action-binding claim against its ``crit`` entry and marker. + + Security.mdx checklist step 4, shared by this module's verifier and the + Web Bot Auth issuer check (``wba.verify_wba_governance_issuer``). + joserfc's ``crit`` check (``JWSRegistry.check_header``) refuses a + ``crit`` that is not a list of registered names, and a marker that is + missing (RFC 7515 section 4.1.11) or not a boolean. The SDK + adds what joserfc leaves open: a repeated entry, a claim present exactly + when its marker is ``true``, and ``authorized_task`` with + ``authorized_payload_hash``. Returns the failure reason, or ``None``. + """ + critical = header.get("crit", []) + markers = { + name: header[name] for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS if name in header + } + try: + _governance_jws_registry("EdDSA").check_header( + {"alg": "EdDSA", "crit": critical, **markers} + ) + except (JoseError, TypeError): + return "invalid governance JWS crit header" + if len(set(critical)) != len(critical): + return "invalid governance JWS crit header" + for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: + if (name in claims) != (name in critical and markers.get(name) is True): + return f"{name} claim and critical marker must match" + if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): + return "authorized_task and authorized_payload_hash must appear together" + return None + + def _governance_jws_registry(alg: str) -> JWSRegistry: """The AdCP JWS registry (``jws._AdcpJwsRegistry``) for one governance ``alg``. diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 9e25d9ef7..fd63183bb 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1314,7 +1314,11 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_invalid", "governance token alg not allowed") if header.get("typ") != WBA_GOVERNANCE_TYP: raise fail("governance_token_invalid", "governance token typ mismatch") - _check_critical_markers(header, claims, fail) + from adcp.governance import critical_claims_failure + + critical_failure = critical_claims_failure(header, claims) + if critical_failure is not None: + raise fail("governance_token_invalid", critical_failure) try: issuer = _https_origin(str(claims.get("iss", ""))) except ValueError as exc: @@ -1367,30 +1371,6 @@ def fail(code: str, reason: str) -> WbaGovernanceError: return issuer -def _check_critical_markers( - header: Mapping[str, Any], claims: Mapping[str, Any], fail: Callable[[str, str], Exception] -) -> None: - """Security.mdx checklist step 4: each action-binding marker, crit entry and claim together.""" - from adcp.governance import GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS - - critical = header.get("crit", []) - if ( - not isinstance(critical, list) - or any(not isinstance(name, str) for name in critical) - or len(set(critical)) != len(critical) - or any(name not in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS for name in critical) - ): - raise fail("governance_token_invalid", "invalid governance JWS crit header") - for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: - if (name in claims) != (name in critical and header.get(name) is True): - raise fail("governance_token_invalid", f"{name} claim and critical marker must match") - if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): - raise fail( - "governance_token_invalid", - "authorized_task and authorized_payload_hash must appear together", - ) - - class WbaRevocationStatusChecker(Protocol): """Report a WBA key's state in its origin's signed revocation list.""" diff --git a/tests/test_governance_authorization.py b/tests/test_governance_authorization.py index 313a4f6d1..fe1561f03 100644 --- a/tests/test_governance_authorization.py +++ b/tests/test_governance_authorization.py @@ -618,3 +618,30 @@ def test_stateless_governance_applicability( task: str, payload: dict[str, Any], expected: bool | None ) -> None: assert stateless_governance_applicability(task, payload) is expected + + +@pytest.mark.parametrize( + ("header", "claims", "reason"), + [ + ({"crit": []}, {}, None), + ({"crit": ["authorized_task"], "authorized_task": True}, {}, "claim"), + ({"crit": ["authorized_commitment"]}, {}, "crit header"), + ({"crit": ["authorized_commitment"], "authorized_commitment": "yes"}, {}, "crit header"), + ({"crit": ["authorized_commitment", "authorized_commitment"]}, {}, "crit header"), + ({"crit": ["unregistered"], "unregistered": True}, {}, "crit header"), + ({"crit": "authorized_commitment"}, {}, "crit header"), + ], +) +def test_critical_claims_follow_rfc_7515_crit_rules(header, claims, reason): + """RFC 7515 section 4.1.11: every crit name occurs in the header. + + A crit entry whose marker is absent or not a boolean is refused even when + its claim is absent too. + """ + from adcp.governance import critical_claims_failure + + failure = critical_claims_failure(header, claims) + if reason is None: + assert failure is None + else: + assert failure is not None and reason in failure From 5f21c22bfddd0f0eab600521c26a58c2125b4b26 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 00:59:08 -0400 Subject: [PATCH 23/46] refactor(governance): build one governance JWS registry The Web Bot Auth governance issuer check built its own joserfc registry, a second copy of governance._governance_jws_registry with its own algorithm table. Use the governance registry for both verifiers, and let _AdcpJwsRegistry know RFC 9864's Ed25519 name, which a Web Bot Auth governance token may carry. Behavior is unchanged. Each caller still narrows the registry to its own algorithms: the revocation-list paths to EdDSA and ES256, the 3.2 governance verifier to the token's EdDSA or ES256, and the WBA check to EdDSA or Ed25519 after it matched the alg to the key. The WBA table's PS512 entry is dead: with Ed25519 keys only, no PS512 token reaches signature verification. joserfc's extract_compact doesn't read the registry's algorithms, so splitting is unchanged too. The governance authorization tests, tests/conformance/signing/test_wba_governance.py, and the JWS and revocation-list tests gate it. --- src/adcp/governance.py | 3 ++ src/adcp/signing/jws.py | 5 +++ src/adcp/signing/wba.py | 31 +++---------------- .../signing/test_wba_governance.py | 4 +-- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/src/adcp/governance.py b/src/adcp/governance.py index 2ed983e32..2414fdf9b 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -1459,6 +1459,9 @@ def critical_claims_failure(header: Mapping[str, Any], claims: Mapping[str, Any] def _governance_jws_registry(alg: str) -> JWSRegistry: """The AdCP JWS registry (``jws._AdcpJwsRegistry``) for one governance ``alg``. + Both governance verifiers use it: this module's and the Web Bot Auth + issuer check (``wba.verify_wba_governance_issuer``). + The critical claim markers are registered as boolean protected-header parameters, so joserfc's ``crit`` check accepts exactly those. """ diff --git a/src/adcp/signing/jws.py b/src/adcp/signing/jws.py index 6a6c28cb4..392d00d2e 100644 --- a/src/adcp/signing/jws.py +++ b/src/adcp/signing/jws.py @@ -97,6 +97,10 @@ def check_key(self, key: Any) -> None: class _AdcpJwsRegistry(JWSRegistry): """joserfc registry that knows only the AdCP profile algorithms. + ``Ed25519`` is RFC 9864's fully specified name for ``EdDSA`` over + Ed25519, which a Web Bot Auth governance token may carry. Each caller + narrows the registry to its own allow-list (``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 @@ -106,6 +110,7 @@ class _AdcpJwsRegistry(JWSRegistry): algorithms: dict[str, JWSAlgModel] = { "EdDSA": _Ed25519EdDSA(), "ES256": ESAlgorithm("ES256", "P-256", 256), + "Ed25519": JWSRegistry.algorithms["Ed25519"], } recommended: list[str] = [] max_header_length = sys.maxsize diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index fd63183bb..7fb73ae86 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1232,31 +1232,6 @@ def __init__(self, code: str, reason: str) -> None: self.reason = reason -def _governance_registry(alg: str) -> Any: - from joserfc.jws import JWSRegistry - from joserfc.registry import HeaderParameter, is_bool - - from adcp.governance import GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS - from adcp.signing.jws import _AdcpJwsRegistry, _Ed25519EdDSA - - class _Registry(_AdcpJwsRegistry): - algorithms = { - "EdDSA": _Ed25519EdDSA(), - "Ed25519": JWSRegistry.algorithms["Ed25519"], - "PS512": JWSRegistry.algorithms["PS512"], - } - - # The action-binding claim markers are the only crit extensions. - return _Registry( - header_registry={ - name: HeaderParameter(f"AdCP critical claim marker: {name}", is_bool) - for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS - }, - algorithms=[alg], - strict_check_header=False, - ) - - async def verify_wba_governance_issuer( token: object, *, @@ -1293,6 +1268,8 @@ async def verify_wba_governance_issuer( from joserfc.jwk import OKPKey from joserfc.util import urlsafe_b64decode + from adcp.governance import _governance_jws_registry + def fail(code: str, reason: str) -> WbaGovernanceError: return WbaGovernanceError(code, reason) @@ -1301,7 +1278,7 @@ def fail(code: str, reason: str) -> WbaGovernanceError: raise fail("governance_token_invalid", "governance token is missing or too large") try: compact = jose_jws.extract_compact( - token.encode("ascii"), registry=_governance_registry("EdDSA") + token.encode("ascii"), registry=_governance_jws_registry("EdDSA") ) header = parse_strict_json(urlsafe_b64decode(compact.segments["header"])) claims = parse_strict_json(compact.payload) @@ -1356,7 +1333,7 @@ def fail(code: str, reason: str) -> WbaGovernanceError: public = {name: value for name, value in key.jwk.items() if name not in {"kid", "alg"}} try: jose_key = OKPKey.import_key(public) - jose_jws.deserialize_compact(token, jose_key, registry=_governance_registry(str(alg))) + jose_jws.deserialize_compact(token, jose_key, registry=_governance_jws_registry(str(alg))) except BadSignatureError as exc: raise fail("governance_token_invalid", "governance token signature invalid") from exc except JoseError as exc: diff --git a/tests/conformance/signing/test_wba_governance.py b/tests/conformance/signing/test_wba_governance.py index e5c0134ee..6e9b037d2 100644 --- a/tests/conformance/signing/test_wba_governance.py +++ b/tests/conformance/signing/test_wba_governance.py @@ -58,7 +58,7 @@ def _governance_token(header_extra=None, claims_extra=None, *, iss=None): from joserfc import jws as jose_jws from joserfc.jwk import OKPKey - from adcp.signing.wba import _governance_registry + from adcp.governance import _governance_jws_registry identity = iss or KEYS[GOVERNANCE_KID]["identity"] header = { @@ -75,7 +75,7 @@ def _governance_token(header_extra=None, claims_extra=None, *, iss=None): header, json.dumps(claims).encode(), OKPKey.import_key(private), - registry=_governance_registry("EdDSA"), + registry=_governance_jws_registry("EdDSA"), ) From 0c76abb1f54afef660bc8a131218fef0147d218c Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:11:47 -0400 Subject: [PATCH 24/46] fix(signing): read a public JWK's key from its public members only crypto.import_jwk hands the whole JWK to joserfc, and joserfc derives the public key from d when d is present. So public_key_from_jwk, the request and webhook verifiers' step 8, and the JWS verifier used the key that d names, not the key that x names. A kid or a publisher pin names a key by its RFC 7638 thumbprint, which covers x (and y) only. A JWKS entry that carried the pinned x beside another key's d passed the pin check, and the signature that d made verified. Before #1537, public_key_from_jwk read x alone. import_jwk now drops the private members unless private=True, which only private_key_from_jwk passes. The Web Bot Auth module takes the private-member set from crypto instead of keeping its own copy. test_public_key_is_read_from_the_public_members and test_reject_signature_by_a_private_member_beside_the_key failed before this change. --- src/adcp/signing/crypto.py | 14 +++++++++--- src/adcp/signing/wba.py | 9 ++++++-- tests/conformance/signing/test_crypto.py | 29 ++++++++++++++++++++++++ tests/conformance/signing/test_jws.py | 12 ++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index a4e9687ea..3422a1351 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -39,6 +39,10 @@ # the same number, so the length is checked here. _P256_MEMBER_BYTES = 32 +# RFC 7518 sections 6.2.2, 6.3.2 and 6.4.1: the members only a private or +# symmetric key carries. +_PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"}) + def b64url_decode(s: str) -> bytes: """Decode RFC 7515 base64url with ``joserfc``: no padding, one spelling. @@ -97,12 +101,16 @@ def load_private_key_pem(pem: bytes, *, password: bytes | None = None) -> Privat return key -def import_jwk(jwk: dict[str, Any]) -> OKPKey | ECKey: +def import_jwk(jwk: dict[str, Any], *, private: bool = False) -> OKPKey | ECKey: """Import an Ed25519 or P-256 JWK with ``joserfc``. ``joserfc`` decodes the members strictly and checks the key. An unusable - JWK raises ``ValueError``. + JWK raises ``ValueError``. Unless ``private``, the private members are + dropped first: ``joserfc`` derives the public key from a ``d`` it is + given, and a ``kid`` or pin names the key by a thumbprint over ``x``. """ + if not private: + jwk = {name: value for name, value in jwk.items() if name not in _PRIVATE_JWK_MEMBERS} kty = jwk.get("kty") crv = jwk.get("crv") key_type: type[OKPKey] | type[ECKey] @@ -137,7 +145,7 @@ def private_key_from_jwk(jwk: dict[str, Any], *, d_field: str = "d") -> PrivateK """ if d_field not in jwk: raise ValueError(f"JWK has no private member {d_field!r}") - private = import_jwk({**jwk, "d": jwk[d_field]}).private_key + private = import_jwk({**jwk, "d": jwk[d_field]}, private=True).private_key return cast(PrivateKey, private) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 7fb73ae86..733f6803f 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -44,7 +44,13 @@ from adcp.signing._header_precheck import strict_header_precheck from adcp.signing._strict_json import StrictJsonError, parse_strict_json from adcp.signing.canonical import _lookup, canonical_origin -from adcp.signing.crypto import b64url_decode, b64url_encode, import_jwk, public_key_from_jwk +from adcp.signing.crypto import ( + _PRIVATE_JWK_MEMBERS, + b64url_decode, + b64url_encode, + import_jwk, + public_key_from_jwk, +) from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches from adcp.signing.jwks import SSRFValidationError, validate_uri_static from adcp.signing.publisher_pins import jwk_thumbprint @@ -77,7 +83,6 @@ _DISCOVERY_NEGATIVE_SECONDS = 60 _DIRECTORY_CREATED_SKEW_SECONDS = 60 _REQUEST_SKEW_SECONDS = 60 -_PRIVATE_JWK_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k", "oth"}) _REQUEST_COMPONENTS = ("@method", "@target-uri", "@authority") _BODY_COMPONENTS = ("content-type", "content-digest") diff --git a/tests/conformance/signing/test_crypto.py b/tests/conformance/signing/test_crypto.py index d51729e43..73ea30e22 100644 --- a/tests/conformance/signing/test_crypto.py +++ b/tests/conformance/signing/test_crypto.py @@ -190,3 +190,32 @@ def test_jwk_members_are_strict_base64url(spoil) -> None: public_key_from_jwk({**jwk, "x": spoil(jwk["x"])}) with pytest.raises(ValueError): b64url_decode(spoil(jwk["x"])) + + +@pytest.mark.parametrize("kid", ["test-ed25519-2026", "test-es256-2026"]) +def test_public_key_is_read_from_the_public_members(kid: str) -> None: + """A ``d`` beside the public members never chooses the verification key. + + A ``kid`` or a publisher pin names a key by its RFC 7638 thumbprint, which + covers ``x`` (and ``y``) only. A key taken from ``d`` would verify a + signature that the named key never made. + """ + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + + from adcp.signing.crypto import b64url_encode + + jwk = {k: v for k, v in KEYS[kid].items() if not k.startswith("_")} + other: ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey + if jwk["kty"] == "OKP": + other = ed25519.Ed25519PrivateKey.generate() + other_d = other.private_bytes_raw() + else: + other = ec.generate_private_key(ec.SECP256R1()) + other_d = other.private_numbers().private_value.to_bytes(32, "big") + raw = serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + expected = public_key_from_jwk(jwk).public_bytes(*raw) + + key = public_key_from_jwk({**jwk, "d": b64url_encode(other_d)}) + + assert key.public_bytes(*raw) == expected diff --git a/tests/conformance/signing/test_jws.py b/tests/conformance/signing/test_jws.py index e82012553..bcb0f9e2f 100644 --- a/tests/conformance/signing/test_jws.py +++ b/tests/conformance/signing/test_jws.py @@ -339,6 +339,18 @@ def test_reject_non_json_payload() -> None: verify_jws_document(token, jwks_resolver=_resolver_for(jwk), expected_typ=EXPECTED_TYP) +def test_reject_signature_by_a_private_member_beside_the_key() -> None: + """The resolved key is its public members; a ``d`` naming another key never verifies.""" + jwk, _key = _ed25519_jwk_and_key() + other = ed25519.Ed25519PrivateKey.generate() + header = {"alg": "EdDSA", "kid": jwk["kid"], "typ": EXPECTED_TYP} + token = _sign_compact(header, PAYLOAD_JSON, jws_alg="EdDSA", private_key=other) + carrying = {**jwk, "d": b64url_encode(other.private_bytes_raw())} + + with pytest.raises(JwsSignatureInvalidError): + verify_jws_document(token, jwks_resolver=_resolver_for(carrying), expected_typ=EXPECTED_TYP) + + def test_verify_jws_document_rejects_non_string_non_dict() -> None: with pytest.raises(JwsMalformedError, match="compact string or JSON"): verify_jws_document( From 4324ba2ed54965c6e87c276b2eb814c1d7ce7408 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:12:25 -0400 Subject: [PATCH 25/46] style(governance): restore the black formatting of the replay binding table Commit 3f0c801fd rewrapped one annotated assignment in governance.py into a shape that black, the formatter that .pre-commit-config.yaml runs at 24.10.0, refuses. This puts back the line as black writes it, which is the line on the base branch. --- src/adcp/governance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/adcp/governance.py b/src/adcp/governance.py index 2414fdf9b..4988f3de8 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -211,9 +211,9 @@ def __init__(self, replay_store: ReplayStore) -> None: if not supports_atomic_claim(replay_store): raise TypeError("governance replay protection requires an atomic replay store") self._store = replay_store - self._bindings: dict[ - tuple[str, str, str], tuple[float, GovernanceReplayBinding | None] - ] = {} + self._bindings: dict[tuple[str, str, str], tuple[float, GovernanceReplayBinding | None]] = ( + {} + ) self._binding_lock = threading.RLock() self._claims_since_sweep = 0 From 80576244bbe5475790e14239650550ed3b8005b4 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:14:00 -0400 Subject: [PATCH 26/46] fix(governance): require each critical marker, its crit entry, and its claim together security.mdx checklist step 4 (AdCP 3.2.1 to 3.2.3) requires "each boolean protected-header marker, its crit entry, and its payload claim to appear together", and the unknown-extension rule says verifiers "MUST also reject when the critical marker and its corresponding claim do not appear together". critical_claims_failure compared the claim only against a crit entry with a true marker, so it accepted a marker for a claim the payload doesn't carry: a crit entry with a false marker, or a marker with no crit entry. Both governance verifiers share the helper. The helper now requires the three to be present together, with the marker true. test_critical_marker_crit_entry_and_claim_appear_together failed before this change. A repeated crit entry with its marker present is now a case of test_critical_claims_follow_rfc_7515_crit_rules, so the duplicate check has a case that only it refuses. --- src/adcp/governance.py | 10 +++++---- tests/test_governance_authorization.py | 28 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/adcp/governance.py b/src/adcp/governance.py index 4988f3de8..081a62458 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -1432,9 +1432,10 @@ def critical_claims_failure(header: Mapping[str, Any], claims: Mapping[str, Any] joserfc's ``crit`` check (``JWSRegistry.check_header``) refuses a ``crit`` that is not a list of registered names, and a marker that is missing (RFC 7515 section 4.1.11) or not a boolean. The SDK - adds what joserfc leaves open: a repeated entry, a claim present exactly - when its marker is ``true``, and ``authorized_task`` with - ``authorized_payload_hash``. Returns the failure reason, or ``None``. + adds what joserfc leaves open: a repeated entry, each marker, its + ``crit`` entry, and its claim appearing together with the marker + ``true``, and ``authorized_task`` with ``authorized_payload_hash``. + Returns the failure reason, or ``None``. """ critical = header.get("crit", []) markers = { @@ -1449,7 +1450,8 @@ def critical_claims_failure(header: Mapping[str, Any], claims: Mapping[str, Any] if len(set(critical)) != len(critical): return "invalid governance JWS crit header" for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS: - if (name in claims) != (name in critical and markers.get(name) is True): + together = (name in claims) == (name in critical) == (name in markers) + if not together or markers.get(name, True) is not True: return f"{name} claim and critical marker must match" if ("authorized_task" in claims) != ("authorized_payload_hash" in claims): return "authorized_task and authorized_payload_hash must appear together" diff --git a/tests/test_governance_authorization.py b/tests/test_governance_authorization.py index fe1561f03..c95d37a1e 100644 --- a/tests/test_governance_authorization.py +++ b/tests/test_governance_authorization.py @@ -630,6 +630,11 @@ def test_stateless_governance_applicability( ({"crit": ["authorized_commitment", "authorized_commitment"]}, {}, "crit header"), ({"crit": ["unregistered"], "unregistered": True}, {}, "crit header"), ({"crit": "authorized_commitment"}, {}, "crit header"), + ( + {"crit": ["authorized_commitment"] * 2, "authorized_commitment": True}, + {"authorized_commitment": {}}, + "crit header", + ), ], ) def test_critical_claims_follow_rfc_7515_crit_rules(header, claims, reason): @@ -645,3 +650,26 @@ def test_critical_claims_follow_rfc_7515_crit_rules(header, claims, reason): assert failure is None else: assert failure is not None and reason in failure + + +@pytest.mark.parametrize( + "header", + [ + {"crit": ["authorized_commitment"], "authorized_commitment": False}, + {"crit": [], "authorized_commitment": True}, + {"crit": [], "authorized_commitment": False}, + ], + ids=["crit-entry-and-false-marker", "marker-alone", "false-marker-alone"], +) +def test_critical_marker_crit_entry_and_claim_appear_together(header): + """security.mdx checklist step 4: marker, crit entry, and claim appear together. + + "Verifiers MUST also reject when the critical marker and its corresponding + claim do not appear together" (security.mdx, AdCP 3.2.1 to 3.2.3). Each + header here carries a marker for a claim the payload doesn't have. + """ + from adcp.governance import critical_claims_failure + + failure = critical_claims_failure(header, {}) + + assert failure == "authorized_commitment claim and critical marker must match" From 68d74a8dce89f6b6b18d618c7dd10ef02dafe474 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:14:54 -0400 Subject: [PATCH 27/46] refactor(signing): drop rsa-pss-sha512 from the RFC 9421 algorithm table With Web Bot Auth on ed25519 only, nothing reaches the rsa-pss-sha512 entry of _rfc9421.ALGORITHMS. The AdCP request and webhook profiles check alg against ALLOWED_ALGS (ed25519 and ecdsa-p256-sha256) before the table, and the Web Bot Auth signer and verifier take ed25519 from the key. The table and the module text now name the two algorithms the SDK signs and verifies with. --- src/adcp/signing/_rfc9421.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index b8378a5e6..cf858ac8a 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -4,7 +4,7 @@ * ``http-message-signatures`` builds every signature base, assembles ``Signature-Input``, and signs and verifies with its algorithm classes - (``ed25519``, ``ecdsa-p256-sha256`` and ``rsa-pss-sha512``). + (``ed25519`` and ``ecdsa-p256-sha256``). * ``http-sf`` parses and serializes Structured Fields, and reports a duplicate dictionary key or parameter through ``on_duplicate_key`` so the SDK can refuse it instead of keeping the last value. @@ -54,7 +54,6 @@ from http_message_signatures.algorithms import ( ECDSA_P256_SHA256, ED25519, - RSA_PSS_SHA512, HTTPSignatureAlgorithm, ) from http_message_signatures.exceptions import HTTPMessageSignaturesException @@ -68,7 +67,7 @@ #: RFC 9421 algorithm names mapped to the library's algorithm classes. Each #: profile narrows this to its own allow-list before anything reaches it. ALGORITHMS: dict[str, type[HTTPSignatureAlgorithm]] = { - algorithm.algorithm_id: algorithm for algorithm in (ED25519, ECDSA_P256_SHA256, RSA_PSS_SHA512) + algorithm.algorithm_id: algorithm for algorithm in (ED25519, ECDSA_P256_SHA256) } From ff41c6ea9771bf638f4426bcb30ae37e248e0497 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:31:33 -0400 Subject: [PATCH 28/46] test(signing): vendor the wba-profile vectors from the current #7894 head adcontextprotocol/adcp#7894 moved from 49b3a315 to 2546cf5f, which adds scenarios R18c and R18d: a Signature-Agent member with type=directory as a Token and as a String verifies. Only scenarios.json changes; the request and directory vectors are byte-identical. The provenance record, the vectors README, and the provenance test name the new commit. docs/agent-identity-3-3-migration.md still named e24eeb09, the commit before 49b3a315, as the profile it follows; it names 2546cf5f now. --- docs/agent-identity-3-3-migration.md | 2 +- tests/conformance/signing/test_wba_profile.py | 2 +- .../conformance/vectors/wba-profile/README.md | 2 +- .../vectors/wba-profile/UPSTREAM.json | 4 ++-- .../vectors/wba-profile/scenarios.json | 24 +++++++++++++++++++ 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index e07111ba2..a2c4435f3 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -1,6 +1,6 @@ # Experimental AdCP 3.3 agent identity -This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `e24eeb09` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. +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 follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `2546cf5f` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both 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. diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index d64509a5b..ed152c0eb 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -59,7 +59,7 @@ async def resolve(keyid, *, signer_origin=None): def test_vendored_vectors_match_recorded_provenance(): upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) - assert upstream["commit"] == "49b3a3157e55a7ec37816908ec28a2e25057fd89" + assert upstream["commit"] == "2546cf5f449d6f30f2f4ff5fb27433ddf3986167" vendored = { str(path.relative_to(ROOT)) for path in ROOT.rglob("*") diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index e3e1dab1b..1eab362e5 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,7 +1,7 @@ # Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`49b3a3157e55a7ec37816908ec28a2e25057fd89`, +`2546cf5f449d6f30f2f4ff5fb27433ddf3986167`, `static/compliance/source/test-vectors/wba-profile/`, on 2026-10-18. The upstream `README.md` is vendored as `UPSTREAM-README.md`. `UPSTREAM.json` records each file's SHA-256, and diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json index af7294702..5938a3474 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM.json +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -1,7 +1,7 @@ { "repository": "adcontextprotocol/adcp", "pull_request": 7894, - "commit": "49b3a3157e55a7ec37816908ec28a2e25057fd89", + "commit": "2546cf5f449d6f30f2f4ff5fb27433ddf3986167", "path": "static/compliance/source/test-vectors/wba-profile/", "vendored_on": "2026-10-18", "renamed": { @@ -18,6 +18,6 @@ "negative/006-two-signatures.json": "87cb93e1f9bc4fba2eadb0df7832d51c9edf2d0add02aab5cee8265b9d7f1661", "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", - "scenarios.json": "858737d50bd24da316f628cd05e70e8f73e3103414009c59b135151a356b5903" + "scenarios.json": "7dd4722ea5de263620ba18b910ae77b171b831a80d0fc62bdbbe13cf208af530" } } diff --git a/tests/conformance/vectors/wba-profile/scenarios.json b/tests/conformance/vectors/wba-profile/scenarios.json index dff46e30f..d5ef0beeb 100644 --- a/tests/conformance/vectors/wba-profile/scenarios.json +++ b/tests/conformance/vectors/wba-profile/scenarios.json @@ -372,6 +372,30 @@ "tier": "adcp", "basis": "Profile: only type=directory; WBA section 5.2.1 lets a verifier ignore other types" }, + { + "id": "R18c", + "name": "Signature-Agent member with type=directory as a Token", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\";type=directory", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "WBA section 5.2.1: type is a Token, and directory is the default" + }, + { + "id": "R18d", + "name": "Signature-Agent member with type=\"directory\" as a String", + "base": "post", + "signature_agent": "sig1=\"https://buyer-7k3q.com\";type=\"directory\"", + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "Profile: a verifier accepts type=directory as a Token or a String, as the reference library does; WBA section 5.2.1 defines a Token" + }, { "id": "R19", "name": "Signature-Agent member keyed differently from the label", From f52410f9e0d205bbc735f893b4d8478317b83ec5 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:32:38 -0400 Subject: [PATCH 29/46] ci(signing): run the wba-interop harness at the current #8298 head adcontextprotocol/adcp#8298 was force-pushed: its head is 6bf069b6, and a7708e41, the commit ADCP_REF named, is no longer on the branch. A commit no ref reaches can stop being fetchable. The new head also grades scenarios R18c and R18d, type=directory as a Token and as a String, which raises the check count from 52 to 54. The SDK adapter passes all 54 at this commit. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06616f30f..dc209f715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -382,7 +382,7 @@ jobs: # adcontextprotocol/adcp commit that carries tests/wba-interop and the wba-profile # vectors: the head of adcontextprotocol/adcp#8298. Pin its merge commit once it # merges, and a release tag once one carries the harness. - ADCP_REF: "a7708e41fbcf0da84273a988c519b25a14b41dbc" + ADCP_REF: "6bf069b61f8bb921983b6fbabd2cfce0962fefb8" steps: - uses: actions/checkout@v6 with: From baebdb5b21a5bccb533e26a7fdfa685b69202959 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:44:20 -0400 Subject: [PATCH 30/46] fix(signing): refuse a PS512 governance token before key discovery verify_wba_governance_issuer screened the token's alg against a literal set that still held PS512 after the profile went to Ed25519 only. A PS512 token passed the screen, made the verifier fetch the issuer's key directory, and failed only at the key-to-alg match. The screen now reads the profile's own table, _GOVERNANCE_JWS_ALGS, so the token is refused with governance_token_invalid before any fetch. test_governance_alg_outside_the_profile_is_refused_before_key_discovery failed before this change. --- src/adcp/signing/wba.py | 2 +- .../signing/test_wba_governance.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 733f6803f..6a5d0c100 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1292,7 +1292,7 @@ def fail(code: str, reason: str) -> WbaGovernanceError: if not isinstance(header, dict) or not isinstance(claims, dict): raise fail("governance_token_invalid", "governance token header or claims not an object") alg = header.get("alg") - if alg not in {"EdDSA", "Ed25519", "PS512"}: + if alg not in _GOVERNANCE_JWS_ALGS[ALG_ED25519]: raise fail("governance_token_invalid", "governance token alg not allowed") if header.get("typ") != WBA_GOVERNANCE_TYP: raise fail("governance_token_invalid", "governance token typ mismatch") diff --git a/tests/conformance/signing/test_wba_governance.py b/tests/conformance/signing/test_wba_governance.py index 6e9b037d2..7d9f5e3e5 100644 --- a/tests/conformance/signing/test_wba_governance.py +++ b/tests/conformance/signing/test_wba_governance.py @@ -123,3 +123,31 @@ async def resolve(keyid, *, signer_origin=None): now=1100, ) assert caught.value.code == "governance_issuer_not_authorized" + + +async def test_governance_alg_outside_the_profile_is_refused_before_key_discovery(): + """Only Ed25519 signs under the profile, so a PS512 token never reaches discovery.""" + from adcp.signing.crypto import b64url_encode + + identity = KEYS[GOVERNANCE_KID]["identity"] + header = { + "alg": "PS512", + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": GOVERNANCE_KID, + } + claims = {"iss": identity, "aud": "https://seller-7k3q.com/mcp/", "iat": 1000, "exp": 1600} + token = ".".join( + b64url_encode(part) + for part in (json.dumps(header).encode(), json.dumps(claims).encode(), b"\x00" * 256) + ) + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("a token alg the profile doesn't sign with must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, signer_keys=resolve, governance_agents=[identity], now=1100 + ) + assert caught.value.code == "governance_token_invalid" + assert caught.value.reason == "governance token alg not allowed" From 528d5eb65cd7bfaa5e39a56f9dd524d070ea0d9e Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 23:48:36 -0400 Subject: [PATCH 31/46] fix(signing): answer a non-string WBA governance alg with governance_token_invalid verify_wba_governance_issuer tested the header's alg for membership in a set. A JSON list or object there is unhashable, so the verifier raised TypeError instead of refusing the token. It now refuses an alg that isn't a string as governance_token_invalid, the code the other malformed-header cases get. test_a_non_string_governance_alg_is_an_invalid_token failed before this change. The 3.2 governance verifier gets the same fix on main. --- src/adcp/signing/wba.py | 2 +- .../signing/test_wba_governance.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 6a5d0c100..1d5fc47a1 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1292,7 +1292,7 @@ def fail(code: str, reason: str) -> WbaGovernanceError: if not isinstance(header, dict) or not isinstance(claims, dict): raise fail("governance_token_invalid", "governance token header or claims not an object") alg = header.get("alg") - if alg not in _GOVERNANCE_JWS_ALGS[ALG_ED25519]: + if type(alg) is not str or alg not in _GOVERNANCE_JWS_ALGS[ALG_ED25519]: raise fail("governance_token_invalid", "governance token alg not allowed") if header.get("typ") != WBA_GOVERNANCE_TYP: raise fail("governance_token_invalid", "governance token typ mismatch") diff --git a/tests/conformance/signing/test_wba_governance.py b/tests/conformance/signing/test_wba_governance.py index 7d9f5e3e5..70ab826ba 100644 --- a/tests/conformance/signing/test_wba_governance.py +++ b/tests/conformance/signing/test_wba_governance.py @@ -151,3 +151,31 @@ async def resolve(keyid, *, signer_origin=None): ) assert caught.value.code == "governance_token_invalid" assert caught.value.reason == "governance token alg not allowed" + + +@pytest.mark.parametrize("alg", [["EdDSA"], {"EdDSA": True}], ids=["list", "object"]) +async def test_a_non_string_governance_alg_is_an_invalid_token(alg): + """A header ``alg`` that isn't a string raises WbaGovernanceError, not TypeError.""" + from adcp.signing.crypto import b64url_encode + + identity = KEYS[GOVERNANCE_KID]["identity"] + header = { + "alg": alg, + "typ": "adcp-gov+jws", + "jku": identity + "/.well-known/http-message-signatures-directory", + "kid": GOVERNANCE_KID, + } + claims = {"iss": identity, "iat": 1000, "exp": 1600} + token = ".".join( + b64url_encode(part) + for part in (json.dumps(header).encode(), json.dumps(claims).encode(), b"\x00" * 64) + ) + + async def resolve(keyid, *, signer_origin=None): + pytest.fail("a malformed alg must not trigger key discovery") + + with pytest.raises(WbaGovernanceError) as caught: + await verify_wba_governance_issuer( + token, signer_keys=resolve, governance_agents=[identity], now=1100 + ) + assert caught.value.code == "governance_token_invalid" From 6f96e5714413e9fb3bc32636324bf3ff1f754952 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:46:48 -0400 Subject: [PATCH 32/46] refactor(signing): split WBA governance tokens with the governance splitter Commit 4e07819a3 moved the AdCP 3.2 governance verifier onto jws.parse_compact_jws and strict segment parsing, but verify_wba_governance_issuer kept its own sequence: joserfc's extract_compact, a base64url decode of the header segment, and two parse_strict_json calls with a separate object check. It now calls parse_compact_jws and governance._decode_json_segment, as the 3.2 verifier does. Every outcome and error code is unchanged. A token whose header or claims is valid JSON but not an object now gets the reason "governance token is not a compact JWS" instead of its own reason; both are governance_token_invalid. tests/conformance/signing/test_wba_governance.py gates it. --- src/adcp/signing/wba.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 1d5fc47a1..803b5fd70 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1271,9 +1271,13 @@ async def verify_wba_governance_issuer( from joserfc import jws as jose_jws from joserfc.errors import BadSignatureError, JoseError from joserfc.jwk import OKPKey - from joserfc.util import urlsafe_b64decode - from adcp.governance import _governance_jws_registry + from adcp.governance import ( + _decode_json_segment, + _governance_jws_registry, + critical_claims_failure, + ) + from adcp.signing.jws import JwsMalformedError, parse_compact_jws def fail(code: str, reason: str) -> WbaGovernanceError: return WbaGovernanceError(code, reason) @@ -1281,23 +1285,18 @@ def fail(code: str, reason: str) -> WbaGovernanceError: now = time.time() if now is None else now if not isinstance(token, str) or not token or len(token) > 16384: raise fail("governance_token_invalid", "governance token is missing or too large") + # The splitter and segment parser of the AdCP 3.2 governance verifier. try: - compact = jose_jws.extract_compact( - token.encode("ascii"), registry=_governance_jws_registry("EdDSA") - ) - header = parse_strict_json(urlsafe_b64decode(compact.segments["header"])) - claims = parse_strict_json(compact.payload) - except (JoseError, StrictJsonError, UnicodeError, ValueError) as exc: + b64_header, b64_payload, _signature = parse_compact_jws(token) + header = _decode_json_segment(b64_header) + claims = _decode_json_segment(b64_payload) + except (JwsMalformedError, ValueError) as exc: raise fail("governance_token_invalid", "governance token is not a compact JWS") from exc - if not isinstance(header, dict) or not isinstance(claims, dict): - raise fail("governance_token_invalid", "governance token header or claims not an object") alg = header.get("alg") if type(alg) is not str or alg not in _GOVERNANCE_JWS_ALGS[ALG_ED25519]: raise fail("governance_token_invalid", "governance token alg not allowed") if header.get("typ") != WBA_GOVERNANCE_TYP: raise fail("governance_token_invalid", "governance token typ mismatch") - from adcp.governance import critical_claims_failure - critical_failure = critical_claims_failure(header, claims) if critical_failure is not None: raise fail("governance_token_invalid", critical_failure) From 4ca804294eb695588f8b7a1fd42f8ab401fa2a67 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 03:50:28 -0400 Subject: [PATCH 33/46] docs(signing): point the governance issuer check at its vectors' new directory Commit d4ca11296 moved the vectors of verify_wba_governance_issuer out of the wba-profile vectors into tests/conformance/vectors/wba-governance/. The migration guide still said the governance/ vectors grade the check. --- docs/agent-identity-3-3-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index a2c4435f3..15bfcebad 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -235,7 +235,7 @@ Onboarding posture is advisory. Sellers still implement requirements, applicatio 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. -**Governance tokens under Web Bot Auth.** `adcp.signing.wba.verify_wba_governance_issuer(token, signer_keys=..., governance_agents=...)` runs the profile's two governance checks (One agent per purpose): the token's `kid` is in the key directory of the `iss` origin and `jku` is that directory's URL (`governance_key_unknown`), and `iss` is the buyer's governance agent (`governance_issuer_not_authorized`), with the JWS, signature and time checks around them. It returns the issuer origin. The rest of the governance checklist (`aud`, `phase`, `sub`, the action binding, revocation, replay) stays with the caller. The `governance/` vectors grade it. 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. +**Governance tokens under Web Bot Auth.** `adcp.signing.wba.verify_wba_governance_issuer(token, signer_keys=..., governance_agents=...)` runs the profile's two governance checks (One agent per purpose): the token's `kid` is in the key directory of the `iss` origin and `jku` is that directory's URL (`governance_key_unknown`), and `iss` is the buyer's governance agent (`governance_issuer_not_authorized`), with the JWS, signature and time checks around them. It returns the issuer origin. The rest of the governance checklist (`aud`, `phase`, `sub`, the action binding, revocation, replay) stays with the caller. The vectors in `tests/conformance/vectors/wba-governance/` grade it. 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. ## Differences from the other SDKs From 90e59ea6d45892225cd2fcc5b66ada6e529081b6 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 12:16:05 -0400 Subject: [PATCH 34/46] test(signing): refuse a directory that publishes a key's private members _parse_directory_keys refuses an entry that carries d, p, q, dp, dq, qi, k, or oth before it skips the keys the profile can't use. Only an oct key with k graded that order. The new test publishes a P-256, an RSA, and an Ed25519 key with its private members beside a usable Ed25519 key that signs, and expects "private key material" for each. If the scan moved after the skip, each directory would verify on the usable key, and all three cases fail, as moving the check showed. _directory_signed_by takes published_private, the signers it lists with their private members. --- tests/test_identity_3_3_alignment.py | 31 ++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 77788aec1..beb685f29 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -699,7 +699,7 @@ def test_unsigned_directories_are_refused_and_keys_are_found_by_thumbprint(): ) -def _public_jwk(private_key): +def _public_jwk(private_key, *, private=False): key_type = { ed25519.Ed25519PrivateKey: OKPKey, ec.EllipticCurvePrivateKey: ECKey, @@ -707,27 +707,29 @@ def _public_jwk(private_key): } imported = next(kind for base, kind in key_type.items() if isinstance(private_key, base)) key = imported.import_key(private_key) - return {**key.as_dict(private=False), "kid": key.thumbprint()} + return {**key.as_dict(private=private), "kid": key.thumbprint()} def _directory_signed_by( signers, *, unsigned=(), + published_private=(), media_type=DIRECTORY_MEDIA_TYPE, components=('"@authority";req', '"content-digest"'), ): """A directory at ``ORIGIN`` that each of ``signers`` signs (Appendix B.1). The library signs it directly, so a key the SDK does not sign with still - signs here. ``unsigned`` keys are listed without a signature. + signs here. ``unsigned`` keys are listed without a signature, and the + signers in ``published_private`` are listed with their private members. """ from http_message_signatures import HTTPMessageSigner from http_message_signatures.algorithms import ECDSA_P256_SHA256, ED25519, RSA_PSS_SHA512 from adcp.signing import _rfc9421 - keys = [_public_jwk(key) for key in signers] + keys = [_public_jwk(key, private=key in published_private) for key in signers] body = json.dumps({"keys": keys + [_public_jwk(key) for key in unsigned]}).encode() message = _rfc9421.Response( status_code=200, @@ -802,6 +804,27 @@ def test_directory_ignores_unusable_keys_and_their_signatures(): assert list(keys) == [public_directory_key(ed_key)["kid"]] +@pytest.mark.parametrize("kind", ["P-256", "RSA", "Ed25519"]) +def test_a_directory_publishing_private_key_material_is_refused(kind): + """A key published with ``d`` fails the directory, whether or not the profile can use it. + + The scan runs before unusable keys are skipped. Each directory here also + holds a usable Ed25519 key that signs, so skipping the key with ``d`` + instead would verify the directory. + """ + usable = ed25519.Ed25519PrivateKey.generate() + exposed = { + "P-256": lambda: ec.generate_private_key(ec.SECP256R1()), + "RSA": lambda: rsa.generate_private_key(public_exponent=65537, key_size=2048), + "Ed25519": ed25519.Ed25519PrivateKey.generate, + }[kind]() + directory = _directory_signed_by([usable, exposed], published_private=[exposed]) + assert "d" in json.loads(directory.body)["keys"][1] + + with pytest.raises(WbaVerificationError, match="private key material"): + verify_directory_response(ORIGIN, directory, now=1001) + + def test_every_usable_directory_key_signs(): """A usable key that does not sign refuses the directory: one signature per key.""" signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) From 2fb5e4e0210557206318a92b6d41295b775f934d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sun, 18 Oct 2026 12:51:43 -0400 Subject: [PATCH 35/46] test(signing): grade against #8298 at ef966de1 and the #7894 vectors at f311e1b5 adcontextprotocol/adcp#8298 moved to ef966de1 and #7894 to f311e1b5. ADCP_REF names the new harness commit. The wba-profile vectors are vendored from f311e1b5: scenarios.json and the upstream README change, and the request and directory vectors are byte-identical. The provenance record, the vectors README, the provenance test, and the migration guide name the new commit. The new scenarios include D14 and D14b: a directory in which an extra P-256 key, or the signing Ed25519 key, carries its private d is refused. The profile at f311e1b5 also records a nonce only after the request verifies; verify_wba_request claims the nonce after the signature verifies. The SDK adapter passes all 63 checks at ef966de1. --- .github/workflows/ci.yml | 2 +- docs/agent-identity-3-3-migration.md | 2 +- tests/conformance/signing/test_wba_profile.py | 2 +- .../conformance/vectors/wba-profile/README.md | 2 +- .../vectors/wba-profile/UPSTREAM-README.md | 4 +- .../vectors/wba-profile/UPSTREAM.json | 6 +- .../vectors/wba-profile/scenarios.json | 167 +++++++++++++++++- 7 files changed, 173 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc209f715..4c09cbeb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -382,7 +382,7 @@ jobs: # adcontextprotocol/adcp commit that carries tests/wba-interop and the wba-profile # vectors: the head of adcontextprotocol/adcp#8298. Pin its merge commit once it # merges, and a release tag once one carries the harness. - ADCP_REF: "6bf069b61f8bb921983b6fbabd2cfce0962fefb8" + ADCP_REF: "ef966de19affc8b36a28f2fbc7c508050ce92d7b" steps: - uses: actions/checkout@v6 with: diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 15bfcebad..4fd94fb99 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -1,6 +1,6 @@ # Experimental AdCP 3.3 agent identity -This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `2546cf5f` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. +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 follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `f311e1b5` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both 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. diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index ed152c0eb..4ca166a6e 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -59,7 +59,7 @@ async def resolve(keyid, *, signer_origin=None): def test_vendored_vectors_match_recorded_provenance(): upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) - assert upstream["commit"] == "2546cf5f449d6f30f2f4ff5fb27433ddf3986167" + assert upstream["commit"] == "f311e1b567c55c1d0689b2b7fd9b4a35e9ba91fe" vendored = { str(path.relative_to(ROOT)) for path in ROOT.rglob("*") diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index 1eab362e5..ec3d5585e 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,7 +1,7 @@ # Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`2546cf5f449d6f30f2f4ff5fb27433ddf3986167`, +`f311e1b567c55c1d0689b2b7fd9b4a35e9ba91fe`, `static/compliance/source/test-vectors/wba-profile/`, on 2026-10-18. The upstream `README.md` is vendored as `UPSTREAM-README.md`. `UPSTREAM.json` records each file's SHA-256, and diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md index fef711e78..7e779338c 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM-README.md +++ b/tests/conformance/vectors/wba-profile/UPSTREAM-README.md @@ -10,7 +10,7 @@ Specification: [Web Bot Auth request-signing profile](https://adcontextprotocol. `keys.json` publishes the private seed of every test key in `_private_d_for_test_only`, so implementations can reproduce every signature. Each seed is SHA-256 of the ASCII string `rfc004-test/`, used as the Ed25519 private key. Each `kid` is the key's JWK thumbprint (RFC 7638; RFC 8037 Appendix A.3 for Ed25519), and each entry's `identity` is the origin whose key directory publishes the key. -**These keys are valid only for grading against this suite.** WBA section 6.8: test keys "MUST NOT be used in production", and verifiers SHOULD reject them when detected. A production verifier that accepts a directory containing `6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I`, or `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI` is exploitable. +**These keys are valid only for grading against this suite.** WBA section 6.8: test keys "MUST NOT be used in production", and verifiers SHOULD reject them when detected. A production verifier that accepts a directory containing `6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I` or `hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI` is exploitable. The domains in the vectors (`buyer-7k3q.com`, `agent.brand-7k3q.com`, `seller-7k3q.com`) are placeholders that resolve to nothing. A harness serves each directory from the vector's `directories` object instead of fetching it. @@ -106,7 +106,7 @@ Every request vector has this shape: - **`signer`**: the identity and `kid` from `keys.json` that sign every scenario. - **`requests`**, **`components`**, and **`parameters`**: the base requests (`post` and `get`), the components each one covers, and the default signature parameters. `{label}` in a component stands for the signature's label. `created` and `expires` are offsets in seconds from the harness's clock. A `nonce` object asks for that many random bytes in that encoding: `base64url` (unpadded), `base64` (canonical padded), or `base64url-padded`. A `base64url-padded` value contains at least one `-` or `_`; without one, it is also canonical padded base64, which the profile accepts. - **`request_scenarios`**: each entry names a `base` request and overrides any of `label`, `components`, `parameters` (a `null` value omits the parameter), and `signature_agent` (the header value). `extra_signature` adds a second signature with its own label, key, components, and parameters. `after_signing` changes `headers`, appends `append_to_body` to the body, or appends raw `append_members` to `Signature-Input` and `Signature` after signing. `directories` lists the `kid` values each origin serves; it defaults to the signer's own directory. -- **`directory_scenarios`**: each entry overrides how the signer's directory response is signed: `keys` (the kids that sign), `unsigned_keys` (public keys in the body that do not sign), `authority`, `parameters`, `components`, `content_type`, and `after_signing`. +- **`directory_scenarios`**: each entry overrides how the signer's directory response is signed: `keys` (the kids that sign), `unsigned_keys` (public keys in the body that do not sign), `authority`, `parameters` (including `tag`), `components`, `content_type`, `status` (the HTTP status the directory is served with, `200` by default), `publish_private_members` (`true` publishes each signing key with its private members), and `after_signing`. - **`expected`**: `success` and `identity` for a scenario that verifies, or `success: false` and the `status` for a request scenario that a verifier rejects. A directory scenario that a verifier rejects has no status of its own: the request that needed its key gets `403`. - **`tier`**: `wba` when the reference library alone, with no AdCP rules, gives the expected outcome; `adcp` when the bare library gives the opposite outcome and only the profile's rules give the expected one. A WBA MUST that the library does not enforce, such as an `https` origin (`R16`), is `adcp` until the library enforces it. A harness checks every tier against the bare library on every run. - **`basis`**: the rule the scenario grades. Informational. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json index 5938a3474..20affba54 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM.json +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -1,14 +1,14 @@ { "repository": "adcontextprotocol/adcp", "pull_request": 7894, - "commit": "2546cf5f449d6f30f2f4ff5fb27433ddf3986167", + "commit": "f311e1b567c55c1d0689b2b7fd9b4a35e9ba91fe", "path": "static/compliance/source/test-vectors/wba-profile/", "vendored_on": "2026-10-18", "renamed": { "README.md": "UPSTREAM-README.md" }, "sha256": { - "UPSTREAM-README.md": "1e1442e6d27e0f88cd9dc70d9942e1f30bb658c19bde3d53067ee8e64c6d4bad", + "UPSTREAM-README.md": "1dfacdbe62e9157435e6b2bf4b714c193bd4f3126a4b0433299c370d8c1b292b", "keys.json": "257850b14fe217f67596808040e2c7b4f62bd36bf2c25d0712d066733008b100", "negative/001-missing-nonce.json": "79a9de2bc336a3b12b20e8cb026614646dc84dbbf05d897c6c34808682062acf", "negative/002-replayed-nonce.json": "51d2ce5c81d8a8e60b99d5a7eaef80f18baba2a104de93d1fc8aeac5c7aaa7be", @@ -18,6 +18,6 @@ "negative/006-two-signatures.json": "87cb93e1f9bc4fba2eadb0df7832d51c9edf2d0add02aab5cee8265b9d7f1661", "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", - "scenarios.json": "7dd4722ea5de263620ba18b910ae77b171b831a80d0fc62bdbbe13cf208af530" + "scenarios.json": "1e35ed32fc68801fc72c9e923eb7f7dbf9ba43745256ea360a548147a7feb47a" } } diff --git a/tests/conformance/vectors/wba-profile/scenarios.json b/tests/conformance/vectors/wba-profile/scenarios.json index d5ef0beeb..c2beb4b31 100644 --- a/tests/conformance/vectors/wba-profile/scenarios.json +++ b/tests/conformance/vectors/wba-profile/scenarios.json @@ -364,7 +364,7 @@ "id": "R18", "name": "Signature-Agent member with type=jwks_uri", "base": "post", - "signature_agent": "sig1=\"https://buyer-7k3q.com/jwks.json\";type=jwks_uri", + "signature_agent": "sig1=\"https://buyer-7k3q.com\";type=jwks_uri", "expected": { "success": false, "status": 403 @@ -416,6 +416,26 @@ "tier": "adcp", "basis": "WBA section 5.2.1: the member keyed to the label; the library does not check it" }, + { + "id": "R19b", + "name": "Whole Signature-Agent field covered before the member", + "base": "post", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent", + "signature-agent;key=sig1" + ], + "expected": { + "success": true, + "identity": "https://buyer-7k3q.com" + }, + "tier": "wba", + "basis": "RFC 9421 section 2.1: the field and its member are separate components; WBA section 5.2.1 requires only the member keyed to the label" + }, { "id": "R20", "name": "Second signature with tag cdn-edge", @@ -470,6 +490,38 @@ "tier": "adcp", "basis": "Profile: exactly one Signature-Agent member" }, + { + "id": "R21b", + "name": "Two web-bot-auth signatures that cover one Signature-Agent member", + "base": "post", + "extra_signature": { + "label": "sig2", + "kid": "6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I", + "components": [ + "@method", + "@target-uri", + "@authority", + "content-type", + "content-digest", + "signature-agent;key=sig1" + ], + "parameters": { + "created": -5, + "expires": 295, + "nonce": { + "bytes": 64, + "encoding": "base64url" + }, + "tag": "web-bot-auth" + } + }, + "expected": { + "success": false, + "status": 403 + }, + "tier": "wba", + "basis": "Profile: exactly one web-bot-auth signature; WBA section 5.2.2 lets a request carry more" + }, { "id": "R22", "name": "Label other than sig1", @@ -550,8 +602,8 @@ "expected": { "success": false }, - "tier": "wba", - "basis": "Profile: every Ed25519 key signs (Appendix B.1 recommends it)" + "tier": "adcp", + "basis": "Profile: every Ed25519 key signs; WBA Appendix B.1 only RECOMMENDS a signature, and a verifier MAY use an unproven key" }, { "id": "D04", @@ -619,6 +671,115 @@ }, "tier": "adcp", "basis": "WBA section 5.5.1: the well-known directory MUST be served with application/http-message-signatures-directory+json" + }, + { + "id": "D09", + "name": "Directory served with status 203", + "status": 203, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA section 5.5: a status other than 200 is a discovery failure" + }, + { + "id": "D10", + "name": "Signed with tag web-bot-auth", + "parameters": { + "tag": "web-bot-auth" + }, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "WBA Appendix B.1: tag=\"http-message-signatures-directory\"" + }, + { + "id": "D11", + "name": "Second Ed25519 key that does not sign", + "unsigned_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "hHurKlVBnPJ4AGiPyl6HWMhxgkIcRBzaC8qWwEtnuVI", + "x": "JB8XPw49dSvDh0kqkCxtoxiNb_RZFhxPdlc-1GedFSc", + "use": "sig" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: every Ed25519 key in the directory signs" + }, + { + "id": "D12", + "name": "Only a P-256 key, unsigned", + "keys": [], + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: the directory is signed, one signature per Ed25519 key" + }, + { + "id": "D13", + "name": "Extra RSA-PSS key that does not sign", + "unsigned_keys": [ + { + "kty": "RSA", + "e": "AQAB", + "n": "v-QEO4laYaq_rKgLuz0I8hRW_Ig58qmjv_L9cIX_2dCz6pkvMg953c3P3yNtvjl0eLiZNk1tbaWjfdwiMczGUYqhcFutwjbJpny5rMlSOuTkgJEidDpVpsSfa9OwmkRLgn_hjrAkQzKYljsqSN_U9deB6a_NwWiM03MRFLVtcCHJQMEVNCXGvqRIF0MKRVdb3287cgEQ2lvb0g1a84CYzQtbRdWcHnSjhOwF3Y2IH2A2VvQzeCSWWFy2LUr2H09Ce2pvAc-DJX6BdUF5MijFV9SRt4jbPWG28kcrLUEt1GMDynfvMT419dx03kfaZnHkh4I3xXKhcFAIMK4D1WH9bQ", + "alg": "PS512", + "kid": "ugCZP3qkO8gkwu8s71HuHjbt75oPFuBK0Tgb4WkL37k", + "use": "sig" + } + ], + "expected": { + "success": true + }, + "tier": "wba", + "basis": "Profile: ignore keys the profile does not use (RFC 7517 section 5), including a key the library imports" + }, + { + "id": "D14", + "name": "Extra P-256 key that carries its private d", + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig", + "d": "IyiPVggq91xZTs3CxpETaAbK6Qca0DuuD9-DZx7ueHs" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: refuse a directory in which any key carries a private member, checked before unused keys are skipped; WBA section 5.5.1: a verifier SHOULD reject malformed entries" + }, + { + "id": "D14b", + "name": "Signing Ed25519 key published with its private d", + "publish_private_members": true, + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: refuse a directory in which any key carries a private member; WBA section 5.5.1: a verifier SHOULD reject malformed entries" } ] } From 0b7518853575667d7864802ba4ae0cd1d9c0ad8a Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 00:06:38 -0400 Subject: [PATCH 36/46] fix(signing): ask for the key directory's media type only fetch_key_directory sent "Accept: application/http-message-signatures- directory+json, application/jwk-set+json;q=0.9, application/json;q=0.8", while _verify_directory accepts only the directory media type (WBA section 5.5.1). A server that negotiates content could answer with application/json, which the verifier then refuses. The request now offers DIRECTORY_MEDIA_TYPE alone. test_directory_fetch_asks_for_the_directory_media_type_only failed before this change. --- src/adcp/signing/wba.py | 9 ++------- tests/test_wba_outage_security.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 803b5fd70..8a3c773b7 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -773,13 +773,8 @@ async def fetch() -> DirectoryResponse: async with client.stream( "GET", url, - headers={ - "Accept": ( - f"{DIRECTORY_MEDIA_TYPE}, application/jwk-set+json;q=0.9, " - "application/json;q=0.8" - ), - "Accept-Encoding": "identity", - }, + # The only media type _verify_directory accepts (WBA section 5.5.1). + headers={"Accept": DIRECTORY_MEDIA_TYPE, "Accept-Encoding": "identity"}, ) as response: return DirectoryResponse( status=response.status_code, diff --git a/tests/test_wba_outage_security.py b/tests/test_wba_outage_security.py index a988aa82f..96e287121 100644 --- a/tests/test_wba_outage_security.py +++ b/tests/test_wba_outage_security.py @@ -20,6 +20,7 @@ ) from adcp.signing.signer_keys import ResolvedSignerKey from adcp.signing.wba import ( + DIRECTORY_MEDIA_TYPE, DirectoryResponse, DiscoveredSignerKeys, OriginRevocationChecker, @@ -33,6 +34,28 @@ ORIGIN = "https://buyer.platform.com" +async def test_directory_fetch_asks_for_the_directory_media_type_only(monkeypatch): + """WBA section 5.5.1: the verifier accepts only the directory media type. + + Offering any other type lets a server that negotiates content choose a + response the verifier then refuses. + """ + requests = [] + + def serve(request): + requests.append(request) + return httpx.Response(200, content=b"{}") + + transport = httpx.MockTransport(serve) + monkeypatch.setattr( + "adcp.signing.ip_pinned_transport.build_async_ip_pinned_transport", + lambda *args, **kwargs: transport, + ) + await fetch_key_directory(ORIGIN + "/.well-known/http-message-signatures-directory") + (request,) = requests + assert request.headers["Accept"] == DIRECTORY_MEDIA_TYPE + + @pytest.mark.parametrize("status", [302, 401, 500, 404, 410]) async def test_directory_fetch_preserves_error_status_without_reading_body(monkeypatch, status): reads = [] From c82d27a79a0eab5d0240083d7e83f476f15ed6fc Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 00:11:04 -0400 Subject: [PATCH 37/46] fix(signing): read a private JWK from d, and check x against it private_key_from_jwk imported the JWK with joserfc, which requires x. A d-only JWK, which WebhookSender.from_jwk took before #1537, raised ValueError, and a JWK that paired d with another key's x loaded without complaint: the sender then signed with d while its JWK named x's key, and only the receiver noticed. The function now builds the key from d alone (strictly decoded, 32 bytes) and treats the public members as optional. When x (or y) is present, it must be the public key of d, or ValueError is raised at load time. test_a_private_jwk_needs_only_d and the Ed25519 case of test_a_private_jwk_whose_public_members_name_another_key_is_refused failed before this change; a mismatched P-256 pair was already refused. --- src/adcp/signing/crypto.py | 27 ++++++++++++++--- tests/conformance/signing/test_crypto.py | 38 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/adcp/signing/crypto.py b/src/adcp/signing/crypto.py index 3422a1351..cfa3186f9 100644 --- a/src/adcp/signing/crypto.py +++ b/src/adcp/signing/crypto.py @@ -137,16 +137,35 @@ def public_key_from_jwk(jwk: dict[str, Any]) -> PublicKey: def private_key_from_jwk(jwk: dict[str, Any], *, d_field: str = "d") -> PrivateKey: - """Reconstruct a private key from its JWK (see :func:`import_jwk`). + """Reconstruct an Ed25519 or P-256 private key from its JWK's ``d``. `d_field` lets callers read from a non-standard scalar field — the AdCP test vectors store the private half under `_private_d_for_test_only` to signal that the keys are not production-grade. + + The public members are optional. When ``x`` (or ``y``) is present, it must + be the public key of ``d``, so a JWK that pairs ``d`` with another key's + ``x`` raises ``ValueError`` here rather than failing at the receiver. """ - if d_field not in jwk: + d = jwk.get(d_field) + if not isinstance(d, str): raise ValueError(f"JWK has no private member {d_field!r}") - private = import_jwk({**jwk, "d": jwk[d_field]}, private=True).private_key - return cast(PrivateKey, private) + kty, crv = jwk.get("kty"), jwk.get("crv") + scalar = b64url_decode(d) + if len(scalar) != 32: + raise ValueError(f"{kty}/{crv} JWK {d_field} must be 32 bytes") + key: PrivateKey + if kty == "OKP" and crv == "Ed25519": + key = ed25519.Ed25519PrivateKey.from_private_bytes(scalar) + public = OKPKey.import_key(key).as_dict(private=False) + elif kty == "EC" and crv == "P-256": + key = ec.derive_private_key(int.from_bytes(scalar, "big"), ec.SECP256R1()) + public = ECKey.import_key(key).as_dict(private=False) + else: + raise ValueError(f"unsupported JWK kty/crv: {kty}/{crv}") + if any(name in jwk and jwk[name] != public[name] for name in ("x", "y") if name in public): + raise ValueError(f"JWK public members are not the public key of {d_field}") + return key def alg_for_jwk(jwk: dict[str, Any]) -> str: diff --git a/tests/conformance/signing/test_crypto.py b/tests/conformance/signing/test_crypto.py index 73ea30e22..454f6eefc 100644 --- a/tests/conformance/signing/test_crypto.py +++ b/tests/conformance/signing/test_crypto.py @@ -219,3 +219,41 @@ def test_public_key_is_read_from_the_public_members(kid: str) -> None: key = public_key_from_jwk({**jwk, "d": b64url_encode(other_d)}) assert key.public_bytes(*raw) == expected + + +_KEY_TYPES = [("OKP", "Ed25519"), ("EC", "P-256")] + + +def _generate(kty: str, crv: str): + from joserfc.jwk import ECKey, OKPKey + + return (OKPKey if kty == "OKP" else ECKey).generate_key(crv, private=True) + + +def _public_members(private_key, kty: str) -> dict: + from joserfc.jwk import ECKey, OKPKey + + return (OKPKey if kty == "OKP" else ECKey).import_key(private_key).as_dict(private=False) + + +@pytest.mark.parametrize(("kty", "crv"), _KEY_TYPES) +def test_a_private_jwk_needs_only_d(kty, crv) -> None: + """``private_key_from_jwk`` reads ``d``; the public members are optional.""" + key = _generate(kty, crv) + d_only = {"kty": kty, "crv": crv, "d": key.as_dict(private=True)["d"]} + + assert _public_members(private_key_from_jwk(d_only), kty) == key.as_dict(private=False) + + +@pytest.mark.parametrize(("kty", "crv"), _KEY_TYPES) +def test_a_private_jwk_whose_public_members_name_another_key_is_refused(kty, crv) -> None: + """A JWK's ``x`` (and ``y``) must be the public key of its ``d``. + + Otherwise the sender signs with ``d`` while its JWK names another key, and + the mistake surfaces only at the receiver. + """ + key, other = _generate(kty, crv), _generate(kty, crv) + mixed = {**key.as_dict(private=False), "d": other.as_dict(private=True)["d"]} + + with pytest.raises(ValueError, match="public key"): + private_key_from_jwk(mixed) From 4635f7589506a6a8f45a7f9508f32be5dac45a9f Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 00:05:48 -0400 Subject: [PATCH 38/46] fix(signing): verify a Web Bot Auth origin's revocation list as EdDSA only OriginRevocationChecker built its AsyncCachingRevocationChecker without an algorithm list, so averify_jws_document fell back to ALLOWED_JWS_ALGS (EdDSA and ES256). Directory keys are Ed25519 only and joserfc refuses ES256 with an OKP key, so no ES256 list verified, but the verifier still resolved the list's key from the origin's directory before refusing it. The profile's revocation-list JWS is EdDSA only, as the #7894 answer for Python stated. AsyncCachingRevocationChecker and its from_issuer_origin take allowed_algs, defaulting to ALLOWED_JWS_ALGS, and pass it to averify_jws_document. OriginRevocationChecker passes {"EdDSA"}, so a list with any other alg is refused before key resolution. test_a_revocation_list_is_eddsa_only failed for ES256 before this change. --- src/adcp/signing/revocation_fetcher.py | 9 +++++++ src/adcp/signing/wba.py | 2 ++ tests/test_identity_3_3_alignment.py | 33 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/src/adcp/signing/revocation_fetcher.py b/src/adcp/signing/revocation_fetcher.py index 2618a4cf1..38c3fd962 100644 --- a/src/adcp/signing/revocation_fetcher.py +++ b/src/adcp/signing/revocation_fetcher.py @@ -56,6 +56,7 @@ JwksResolver, ) from adcp.signing.jws import ( + ALLOWED_JWS_ALGS, JwsError, averify_jws_document, verify_jws_document, @@ -930,6 +931,9 @@ class AsyncCachingRevocationChecker(_CheckerState): verifying tasks that all miss on the first verification fire exactly one fetch. (The sync class is documented single-threaded; async concurrency is the typical case.) + * ``allowed_algs`` narrows the list's JWS ``alg`` below + :data:`adcp.signing.jws.ALLOWED_JWS_ALGS`, for a profile that signs + with fewer algorithms. Wire it into :class:`adcp.signing.VerifyOptions.revocation_checker` only if you have an async verifier pipeline — the sync @@ -948,6 +952,7 @@ def __init__( clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), history_admission: Callable[[RevocationList], bool] | None = None, + allowed_algs: frozenset[str] = ALLOWED_JWS_ALGS, ) -> None: if clock is time.time: raise ValueError( @@ -969,6 +974,7 @@ def __init__( self._clock = clock self._wall_clock = wall_clock self._history_admission = history_admission + self._allowed_algs = allowed_algs self._init_state() # Eager lock construction: lazy-init was racy (two tasks both # seeing self._lock is None construct separate Locks and skip @@ -988,6 +994,7 @@ def from_issuer_origin( clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), history_admission: Callable[[RevocationList], bool] | None = None, + allowed_algs: frozenset[str] = ALLOWED_JWS_ALGS, ) -> AsyncCachingRevocationChecker: """Async variant of :meth:`CachingRevocationChecker.from_issuer_origin`.""" normalized = _normalize_issuer(origin) @@ -1001,6 +1008,7 @@ def from_issuer_origin( clock=clock, wall_clock=wall_clock, history_admission=history_admission, + allowed_algs=allowed_algs, ) async def aprime(self) -> None: @@ -1125,6 +1133,7 @@ async def _refresh(self, *, conditional: bool, now_wall: datetime, now_mono: flo result.body, jwks_resolver=self._jwks_resolver, expected_typ=REVOCATION_LIST_TYP, + allowed_algs=self._allowed_algs, ) except JwsError as exc: raise RevocationListSignatureError( diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index 8a3c773b7..dfaa25ece 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -1601,6 +1601,8 @@ async def resolve(keyid: str) -> dict[str, Any] | None: clock=self._clock, wall_clock=self._wall_clock, history_admission=self._admit_revocations, + # The profile's revocation-list JWS is EdDSA only. + allowed_algs=frozenset({"EdDSA"}), ) # Authenticated revocations are permanent history. A 404 or # transport failure with no signed evidence consumes no slot. diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index beb685f29..92b5a90d6 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -294,6 +294,39 @@ def _signed_revocations(private, origin, *, start, revoked=()): return FetchResult(f"{protected}.{encoded}.{signature}", etag=None) +@pytest.mark.parametrize("alg", ["ES256", "Ed25519"]) +async def test_a_revocation_list_is_eddsa_only(alg): + """An origin's revocation list under Web Bot Auth is an EdDSA JWS only. + + A list with any other ``alg`` is refused before its key is resolved, so it + never verifies and the origin's state is ``"stale"``. + """ + from datetime import timedelta + + private = ed25519.Ed25519PrivateKey.generate() + jwk = public_directory_key(private) + start = datetime.now(timezone.utc) + resolved = [] + + async def resolve(keyid, *, signer_origin=None): + resolved.append(keyid) + return ResolvedSignerKey(signer_origin, jwk) + + async def fetch(uri, **kwargs): + signed = _signed_revocations(private, ORIGIN, start=start) + _, payload, signature = signed.body.split(".") + protected = b64url_encode( + json.dumps({"alg": alg, "kid": jwk["kid"], "typ": "adcp-gov-revocation+jws"}).encode() + ) + return FetchResult(f"{protected}.{payload}.{signature}", etag=None) + + checker = OriginRevocationChecker( + resolve, fetcher=fetch, clock=lambda: 0.0, wall_clock=lambda: start + timedelta(seconds=1) + ) + assert await checker.status(ORIGIN, jwk["kid"]) == "stale" + assert resolved == [] + + async def test_origin_without_a_revocation_list_is_identity_established_and_unbindable(): from datetime import datetime, timedelta, timezone From 67a844f26a8feb9fec20fc91ee1f0d30c971b550 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 01:41:59 -0400 Subject: [PATCH 39/46] test(signing): refuse a directory that spells a private member in upper case The #8298 D14c case publishes a P-256 key's private scalar as "D". The private-member scan lower-cases each member name, so the SDK already refuses it, but no in-repo test spelled a member in upper case. test_a_private_member_refuses_the_directory_whatever_its_case covers D, P, and Q. It fails when the scan compares names case-sensitively. --- tests/test_identity_3_3_alignment.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index 92b5a90d6..cbfca604e 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -750,12 +750,14 @@ def _directory_signed_by( published_private=(), media_type=DIRECTORY_MEDIA_TYPE, components=('"@authority";req', '"content-digest"'), + entries=(), ): """A directory at ``ORIGIN`` that each of ``signers`` signs (Appendix B.1). The library signs it directly, so a key the SDK does not sign with still signs here. ``unsigned`` keys are listed without a signature, and the signers in ``published_private`` are listed with their private members. + ``entries`` are listed as given, unsigned. """ from http_message_signatures import HTTPMessageSigner from http_message_signatures.algorithms import ECDSA_P256_SHA256, ED25519, RSA_PSS_SHA512 @@ -763,7 +765,8 @@ def _directory_signed_by( from adcp.signing import _rfc9421 keys = [_public_jwk(key, private=key in published_private) for key in signers] - body = json.dumps({"keys": keys + [_public_jwk(key) for key in unsigned]}).encode() + listed = keys + [_public_jwk(key) for key in unsigned] + list(entries) + body = json.dumps({"keys": listed}).encode() message = _rfc9421.Response( status_code=200, request=_rfc9421.Request("GET", ORIGIN + DIRECTORY_PATH), @@ -858,6 +861,22 @@ def test_a_directory_publishing_private_key_material_is_refused(kind): verify_directory_response(ORIGIN, directory, now=1001) +@pytest.mark.parametrize("name", ["D", "P", "Q"]) +def test_a_private_member_refuses_the_directory_whatever_its_case(name): + """The private-member scan ignores case (the #8298 D14c case spells ``D``). + + JWK member names are case-sensitive (RFC 7517 section 4), so ``D`` is not + ``d``. A directory that publishes a private scalar under any spelling is + refused all the same, before its entries are read. + """ + usable = ed25519.Ed25519PrivateKey.generate() + other = ec.generate_private_key(ec.SECP256R1()) + entry = {**_public_jwk(other), name: _public_jwk(other, private=True)["d"]} + + with pytest.raises(WbaVerificationError, match="private key material"): + verify_directory_response(ORIGIN, _directory_signed_by([usable], entries=[entry]), now=1001) + + def test_every_usable_directory_key_signs(): """A usable key that does not sign refuses the directory: one signature per key.""" signed, unsigned = (ed25519.Ed25519PrivateKey.generate() for _ in range(2)) From ab4f883f2cabbfd088f1fe72950588cba4d95177 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 00:14:00 -0400 Subject: [PATCH 40/46] test(signing): let the interop adapter crash on anything but an SDK refusal The adapter turned every exception into {"ok": false}, and the harness grades a directory scenario with no expected status as passed when ok is false. A TypeError in the SDK therefore graded as a correct refusal on D08 to D14c. The adapter now reports only the SDK's refusals: WbaVerificationError, and the ValueError it raises when it refuses to sign (WbaVerificationError subclasses ValueError). Any other exception propagates and the adapter exits with a traceback. test_the_interop_adapter_reports_only_sdk_refusals failed for TypeError and KeyError before this change. --- tests/conformance/signing/test_wba_interop.py | 33 +++++++++++++++++++ tests/wba_interop/adapter.py | 11 ++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/conformance/signing/test_wba_interop.py b/tests/conformance/signing/test_wba_interop.py index 11f663283..08d12fbee 100644 --- a/tests/conformance/signing/test_wba_interop.py +++ b/tests/conformance/signing/test_wba_interop.py @@ -209,3 +209,36 @@ def test_rsa_pss_appendix_e_vector_is_not_a_web_bot_auth_signature(): message = Request("GET", "https://example.com/", headers) with pytest.raises(WbaVerificationError, match="unsupported directory key"): _verify_crypto(message, label="sig2", params=params, jwk=jwk) + + +@pytest.mark.parametrize( + ("error", "refusal"), + [ + (WbaVerificationError("refused", status=403), True), + (ValueError("the SDK refuses to sign"), True), + (TypeError("a bug in the SDK"), False), + (KeyError("request"), False), + ], + ids=["verification-refusal", "signing-refusal", "type-error", "key-error"], +) +async def test_the_interop_adapter_reports_only_sdk_refusals(monkeypatch, error, refusal): + """The harness grades a refusal; any other exception crashes the adapter. + + ``WbaVerificationError`` and ``ValueError`` are how the SDK refuses. Any + other exception is a defect, so the adapter lets it propagate instead of + reporting it as ``ok: false``, which the harness would grade as a correct + refusal. + """ + from tests.wba_interop import adapter + + async def operation(op): + raise error + + monkeypatch.setitem(adapter.OPERATIONS, "probe", operation) + if refusal: + result = await adapter.run({"op": "probe"}) + assert result["ok"] is False + assert result["error"] == str(error) + return + with pytest.raises(type(error)): + await adapter.run({"op": "probe"}) diff --git a/tests/wba_interop/adapter.py b/tests/wba_interop/adapter.py index a71b0b72a..3b9f4f3a2 100644 --- a/tests/wba_interop/adapter.py +++ b/tests/wba_interop/adapter.py @@ -25,7 +25,10 @@ A failure is ``{"ok": false, "status": ..., "rule": ..., "error": ...}``. The status is the one the SDK answers with; an error that carries no HTTP status -(the SDK refusing to sign, for example) reports ``0``. +(the SDK refusing to sign, for example) reports ``0``. Only the SDK's refusals +are failures: ``WbaVerificationError``, and the ``ValueError`` it raises when +it refuses to sign. Any other exception is a defect, and it crashes the +adapter rather than reading as a refusal. Run it as ``uv run python tests/wba_interop/adapter.py``. """ @@ -60,7 +63,7 @@ def _private_key(jwk: Mapping[str, Any]) -> Any: return JWKRegistry.import_key(dict(jwk)).private_key -def _failure(exc: Exception) -> Result: +def _failure(exc: ValueError) -> Result: return { "ok": False, "status": getattr(exc, "status", 0), @@ -145,8 +148,8 @@ async def verify_directory(op: Mapping[str, Any]) -> Result: async def run(op: Mapping[str, Any]) -> Result: try: return await OPERATIONS[op["op"]](op) - except Exception as exc: - # The harness grades the refusal itself, so every SDK error is a result. + except ValueError as exc: # WbaVerificationError is a ValueError + # The harness grades the refusal itself, so a refusal is a result. return _failure(exc) From 369ddf37e4cfb8985f9f3842e210acddc4a851bc Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 00:19:44 -0400 Subject: [PATCH 41/46] docs(signing): list the stricter JWK and base64url decoding for adopters #1537 changes four exported adcp.signing functions that also exist on main 9.x. docs/request-signing-migration.md section 7 lists each change and what an adopter checks before upgrading. BREAKING CHANGE: adcp.signing.b64url_decode accepts only RFC 7515 base64url (no padding, no standard-alphabet characters, no non-zero trailing bits) and raises ValueError for anything else. public_key_from_jwk reads a JWK through joserfc, so its ValueError text changes. private_key_from_jwk raises ValueError when a JWK's x (or y) isn't the public key of its d. adcp.signing.publisher_pins.jwk_thumbprint returns the RFC 7638 thumbprint as a base64url str instead of the digest bytes. --- docs/request-signing-migration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index 1ae8d366a..b74df9040 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -345,6 +345,19 @@ Two ways across: The same function is the identifier comparer for `adagents.json` `authorized_agents[].url`, `brand.json` `agents[].url`, TMP `seller_agent.agent_url`, `format-id.agent_url` and the governance issuer. There the change is strictly more permissive: `https://agent.example.com` and `https://agent.example.com/` now compare equal, where before a publisher who registered the first did not match a caller who arrived as the second, and the refusal named authorization rather than canonicalization. No registration needs editing, and no pair that matched before stops matching. +## 7. Upgrading to strict JWK and base64url decoding + +These `adcp.signing` functions decode keys more strictly than they did in 9.x: + +| Function | Change | +| --- | --- | +| `b64url_decode` | Accepts only RFC 7515 base64url: the URL-safe alphabet, no padding, and the one spelling `b64url_encode` produces. Padding, `+` or `/`, other characters, and non-zero trailing bits raise `ValueError`. | +| `public_key_from_jwk` | Decodes the members with `joserfc` and reads the public members only. Apart from the strict decoding above, it refuses the same keys, but the `ValueError` text changes, for example from `Ed25519 JWK x has length 3, expected 32` to `unusable OKP/Ed25519 JWK: ...`. | +| `private_key_from_jwk` | Reads `d` alone, so a `d`-only JWK still loads. When `x` (or `y`) is present and isn't the public key of `d`, it raises `ValueError` instead of returning the key that `d` names. | +| `publisher_pins.jwk_thumbprint` | Returns the RFC 7638 thumbprint as its base64url `str`, not the digest `bytes`. | + +A key published with padded or standard-alphabet base64 no longer verifies, so check that your JWKS and `adagents.json` entries use unpadded base64url. Code that matched error text, or compared `jwk_thumbprint` with digest bytes, needs updating. + ## Related - [`adcp.signing.install_signing_event_hook`](../src/adcp/signing/client.py) — buyer-side preset for adapters not using `ADCPClient`. From 40155e7f754c35f616cb0ddc85a9991c78cd2481 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 01:42:07 -0400 Subject: [PATCH 42/46] docs(signing): state the basis for signing Web Bot Auth with Ed25519 only The migration guide now says that Web Bot Auth signs with ed25519 following the current #7894 page (5c3876073f). The working group has not ratified that page, so a reversal there restores the removed algorithm here too. --- docs/agent-identity-3-3-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 4fd94fb99..1e7918aa5 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -29,7 +29,7 @@ signing = SigningConfig( 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. -Web Bot Auth signs with `ed25519`, the profile's one algorithm. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys only. ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 or RSA key is refused for Web Bot Auth, and such a key in someone's directory is ignored, with its signature. Every signature names `alg`; a signature without it is refused. +Web Bot Auth signs with `ed25519`, the profile's one algorithm, following the current #7894 page (`5c3876073f`). The working group hasn't recorded a decision on that page; if it restores another algorithm, this SDK follows it. `sign_wba_request`, `public_directory_key` and `sign_key_directory` accept Ed25519 keys only. ES256 (P-256) stays on the AdCP RFC 9421 profile: a P-256 or RSA key is refused for Web Bot Auth, and such a key in someone's directory is ignored, with its signature. Every signature names `alg`; a signature without it is refused. Serve the key directory at `/.well-known/http-message-signatures-directory` on that origin. The profile requires it signed (WBA Appendix B.1); verifiers refuse an unsigned directory: From 8ccabed42f4f0af94e5968b60410959e7e4c7c26 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 02:37:40 -0400 Subject: [PATCH 43/46] refactor(signing)!: adopt http-message-signatures 2.1 and drop the SDK's resolvers http-message-signatures 2.1.0 resolves the RFC 9421 ;key and ;req component parameters (pyauth/http-message-signatures#30) and adds HTTPMessageSigner.prepare and apply for a signature produced elsewhere (#31). It also replaced the resolver's url attribute with url_parts, so the SDK's resolver subclasses, which read url, failed every signature base under 2.1. The SDK now uses the library for what the subclasses did: - Every profile resolves components with HTTPSignatureComponentResolver. ComponentResolver (the ;key and ;req branches, a derived-component allow-list, and its own trimming and control-character checks), ComponentError, AdcpComponentResolver, and Web Bot Auth's _WbaComponentResolver are removed. - The AdCP RFC 9421 profile hands the library a request whose URL is the canonical target URI (canonical.signing_message), so the library derives the AdCP @target-uri and @authority from it. Web Bot Auth hands it the target URI as received. - The async signer uses prepare and apply instead of the protected base hook. build_signature_base still builds a base through the protected _build_signature_base: the library has no public call for a base over parameters the caller orders. The library is the reference for every observable change, and each one follows it: - A covered derived component beyond @method, @target-uri, and @authority, such as @path, resolves and verifies instead of being refused. Neither profile refuses extra components. test_an_extra_covered_derived_component_verifies replaces test_unsupported_derived_component_wraps_as_header_malformed. - A Web Bot Auth @target-uri for a URL with no path ends in "/", and one with a fragment or userinfo drops them. @authority strips a zero-padded default port. - A covered header's obsolete line folding is unfolded (RFC 9421 section 2.1) instead of refused. - A lowercase method, a malformed percent escape, and a raw "{", "}", or "|" in the URL, which RFC 3986 doesn't allow, are refused. The signers' ValueError says to percent-encode the URL. - A Web Bot Auth target URI that isn't a valid URI is 403, the profile's status for a failed verification, instead of 400. Tests that asserted on the removed internals are rewritten against the library: test_rfc9421_library_contract.py drops four tests and adds test_library_resolves_key_and_req and test_verify_tells_a_base_failure_from_a_bad_signature, and the WBA test helpers sign with the library resolver. BREAKING CHANGE: adcp.signing requires http-message-signatures 2.1.0 or later. sign_request, async_sign_request, and sign_wba_request raise ValueError for a lowercase method or a URL that isn't valid RFC 3986, such as one with a raw "{", "}", or "|" in its query, and the verifiers refuse such a request. A covered derived component beyond the required ones verifies. --- docs/request-signing-migration.md | 2 + pyproject.toml | 10 +- src/adcp/signing/_rfc9421.py | 307 ++++++------------ src/adcp/signing/canonical.py | 25 +- src/adcp/signing/signer.py | 18 +- src/adcp/signing/verifier.py | 43 +-- src/adcp/signing/wba.py | 28 -- .../signing/test_rfc9421_library_contract.py | 172 ++++------ .../signing/test_verifier_behaviors.py | 76 +++-- tests/conformance/signing/test_wba_profile.py | 8 +- tests/test_identity_3_3_alignment.py | 23 +- 11 files changed, 295 insertions(+), 417 deletions(-) diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index b74df9040..144529051 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -358,6 +358,8 @@ These `adcp.signing` functions decode keys more strictly than they did in 9.x: A key published with padded or standard-alphabet base64 no longer verifies, so check that your JWKS and `adagents.json` entries use unpadded base64url. Code that matched error text, or compared `jwk_thumbprint` with digest bytes, needs updating. +Signing and verification use `http-message-signatures` 2.1, which parses the URL per RFC 3986, so a URL with a raw `{`, `}`, or `|` in its query can't be signed: percent-encode it. `httpx` encodes those characters when they come through `params=`, but not when they are written into the URL. + ## Related - [`adcp.signing.install_signing_event_hook`](../src/adcp/signing/client.py) — buyer-side preset for adapters not using `ADCPClient`. diff --git a/pyproject.toml b/pyproject.toml index 18eb304bb..2a9e6d18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,13 +140,15 @@ dependencies = [ "idna>=3.18,<4", # RFC 9421 HTTP Message Signatures for every signing profile # (``adcp.signing._rfc9421``): signature bases, ``Signature-Input`` - # assembly, and the ed25519 / ecdsa-p256-sha256 / rsa-pss-sha512 - # algorithms. The SDK supplies a ``component_resolver_class`` and reaches - # two protected subclass hooks (``_build_signature_base``, + # assembly, the ;key and ;req component parameters, prepare/apply for an + # awaited signature, and the ed25519 / ecdsa-p256-sha256 algorithms. 2.1 + # is the floor: it resolves ;key and ;req (pyauth#30) and adds + # prepare/apply (pyauth#31). The diagnostic base builder reaches two + # protected hooks (``_build_signature_base``, # ``_parse_covered_component_ids``); the upper bound and # tests/conformance/signing/test_rfc9421_library_contract.py guard them. # Apache-2.0, depends only on cryptography. - "http-message-signatures>=2.0.1,<3", + "http-message-signatures>=2.1.0,<3", # RFC 8941 / RFC 9651 Structured Fields (successor to the archived # http-sfv). 1.4.0 is the tested floor; ``on_duplicate_key`` lets the SDK # refuse a duplicate dictionary key or parameter instead of keeping the diff --git a/src/adcp/signing/_rfc9421.py b/src/adcp/signing/_rfc9421.py index cf858ac8a..525ffa446 100644 --- a/src/adcp/signing/_rfc9421.py +++ b/src/adcp/signing/_rfc9421.py @@ -10,34 +10,34 @@ SDK can refuse it instead of keeping the last value. What stays here is profile policy, expressed through the library's public -extension points: - -* :class:`ComponentResolver` is the ``component_resolver_class`` the signer - and verifier take. It supplies the RFC 9421 ``;key`` (section 2.1.2) and - ``;req`` (section 2.4) component parameters, which - ``http-message-signatures`` 2.0.1 does not implement yet - (pyauth/http-message-signatures#29; the fix is pyauth#30). Once a release - with that fix is out, the ``key``/``req`` branches here can go. -* Its subclasses decide what ``@target-uri`` and ``@authority`` mean: the AdCP - RFC 9421 profile canonicalizes the URL - (:class:`AdcpComponentResolver`), while Web Bot Auth covers the target URI - as received (:class:`ReceivedComponentResolver`). +API: + +* Every profile resolves components with the library's + ``HTTPSignatureComponentResolver``, which implements the RFC 9421 ``;key`` + (section 2.1.2) and ``;req`` (section 2.4) component parameters. The AdCP + RFC 9421 profile hands the library its canonical target URI + (:func:`adcp.signing.canonical.canonicalize_target_uri`), so ``@target-uri`` + and ``@authority`` are the AdCP values; Web Bot Auth hands it the target + URI as received. * :class:`ProfileVerifier` turns off the library's own wall-clock window check, because every profile checks its window against an injected clock with its own error codes. - -The signer subclass reaches ``_build_signature_base`` only for the async -(:class:`~adcp.signing.SigningProvider`) path, where the base must exist -before an awaited signature; pyauth/http-message-signatures#31 asks for a -public call. ``tests/conformance/signing/test_rfc9421_library_contract.py`` -pins the library shapes this module relies on. +* :func:`prepare` and :func:`apply` are ``HTTPMessageSigner.prepare`` and + ``apply``, for the async (:class:`~adcp.signing.SigningProvider`) path, + where the signature is awaited. + +:func:`build_base` reaches the library's protected ``_build_signature_base``, +because the library has no public call that builds a base for parameters the +caller supplies in their own order. +``tests/conformance/signing/test_rfc9421_library_contract.py`` pins the +library shapes this module relies on. """ from __future__ import annotations import base64 import binascii -from collections import OrderedDict, UserString +from collections import UserString from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime, timezone @@ -57,7 +57,7 @@ HTTPSignatureAlgorithm, ) from http_message_signatures.exceptions import HTTPMessageSignaturesException -from http_message_signatures.structures import CaseInsensitiveDict +from http_message_signatures.structures import CaseInsensitiveDict, PreparedSignature from http_sf.bare_item import parse_bare_item from http_sf.innerlist import ser_item_or_inner_list from http_sf.item import parse_item, ser_item @@ -321,105 +321,6 @@ class Response: def __post_init__(self) -> None: self.headers = _first_wins(self.headers) - @property - def url(self) -> str: - return self.request.url - - -class ComponentError(HTTPMessageSignaturesException): - """A covered component this profile cannot resolve. - - Raised by the resolvers so a verifier can tell a component that does not - resolve (a malformed request) from a signature that does not verify. - ``status`` is the Web Bot Auth status for the failure. - """ - - def __init__(self, reason: str, *, status: int = 403) -> None: - super().__init__(reason) - self.reason = reason - self.status = status - - -class ComponentResolver(HTTPSignatureComponentResolver): - """The ``component_resolver_class`` every SDK signer and verifier uses. - - Adds what the profiles need on top of the library resolver: - - * ``;key=""`` on a covered Dictionary field resolves to that - member's serialization (RFC 9421 section 2.1.2); - * ``;req`` on a response resolves the component from the request it - answers (section 2.4); - * a covered field's value is trimmed of surrounding whitespace (section - 2.1) and refused when it carries a line break or NUL; - * only the derived components in :attr:`derived_component_names` resolve. - - pyauth/http-message-signatures#30 moves ``key`` and ``req`` into the - library; drop those branches once a release carries it. - """ - - derived_component_names = {"@method", "@target-uri", "@authority"} - - def resolve(self, component_node: Any) -> str: - name = component_node.value - params = dict(component_node.params) - if type(name) is not str: - raise ComponentError("a covered component name must be an sf-string", status=400) - if name != name.lower(): - raise ComponentError("covered component names must be lowercase") - if "req" in params: - if params.pop("req") is not True: - raise ComponentError("the req parameter is a boolean flag") - if not isinstance(self.message, Response): - raise ComponentError("the req parameter only applies to a response") - request_resolver = type(self)(self.message.request) - return request_resolver._resolve(name, params) - return self._resolve(name, params) - - def _resolve(self, name: str, params: dict[str, Any]) -> str: - if name.startswith("@"): - if params: - raise ComponentError("unsupported derived component parameters") - if name not in self.derived_component_names: - raise ComponentError(f"unsupported derived component {name}") - value: str = getattr(self, "get_" + name[1:].replace("-", "_"))() - return value - raw = self.headers.get(name) - if raw is None: - raise ComponentError(f"covered field {name!r} is missing") - if any(c in raw for c in "\r\n\x00"): - raise ComponentError("a covered field carries a control character", status=400) - if not params: - return str(raw).strip(" \t") - member_key = params.pop("key", None) - if params or type(member_key) is not str: - raise ComponentError("unsupported field component parameters") - try: - dictionary = parse_dictionary(raw) - except ValueError as exc: - raise ComponentError(f"covered field {name!r} is malformed", status=400) from exc - if member_key not in dictionary: - raise ComponentError(f"covered dictionary member {member_key!r} is missing") - return ser_item_or_inner_list(dictionary[member_key]) - - -class AdcpComponentResolver(ComponentResolver): - """The AdCP RFC 9421 profile: ``@target-uri`` and ``@authority`` canonicalized. - - The canonicalization is the AdCP URL algorithm in - :mod:`adcp.signing.canonical`. Its ``TargetUriMalformedError`` propagates - so the verifier reports ``request_target_uri_malformed``. - """ - - def get_target_uri(self) -> str: - from adcp.signing.canonical import canonicalize_target_uri - - return canonicalize_target_uri(self.url) - - def get_authority(self) -> str: - from adcp.signing.canonical import canonicalize_authority - - return canonicalize_authority(self.url) - # --------------------------------------------------------------------------- # Signing and verification on the library @@ -440,30 +341,11 @@ def resolve_public_key(self, key_id: str) -> Any: return self._public_key -class ProfileSigner(HTTPMessageSigner): - """``HTTPMessageSigner`` that can also hand out a signature base. - - ``HTTPMessageSigner.sign`` signs in one step. The async - (:class:`~adcp.signing.SigningProvider`) path awaits the signature, so it - needs the base first, and the public ``build_signature_base`` helper - returns one. Both reuse the library's own base builder through its - protected subclass hooks; the contract test pins them. Drop the hooks - once a release carries pyauth/http-message-signatures#31. - """ - - def base( - self, - message: Request | Response, - *, - covered_component_ids: Sequence[str], - params: Mapping[str, Any], - ) -> tuple[bytes, str]: - base, node, _ = self._build_signature_base( - message, - covered_component_ids=self._parse_covered_component_ids(covered_component_ids), - signature_params=dict(params), - ) - return base.encode("utf-8"), str(node) +def _signer(alg: str, private_key: Any = None) -> HTTPMessageSigner: + return HTTPMessageSigner( + signature_algorithm=ALGORITHMS[alg], + key_resolver=_Key(private_key=private_key) if private_key is not None else None, + ) def build_base( @@ -471,23 +353,39 @@ def build_base( *, covered_component_ids: Sequence[str], params: Mapping[str, Any], - resolver: type[ComponentResolver], ) -> bytes: """The RFC 9421 signature base for these components and parameters. ``params`` are serialized in the order given. A component that does not - resolve raises ``ValueError`` (an AdCP canonicalization failure keeps its - own ``ValueError`` subclass). + resolve raises ``ValueError``. """ - handler = ProfileSigner( - signature_algorithm=ED25519, key_resolver=_Key(), component_resolver_class=resolver - ) + signer = _signer(ED25519.algorithm_id) try: - return handler.base(message, covered_component_ids=covered_component_ids, params=params)[0] - except ComponentError as exc: - raise ValueError(exc.reason) from exc + base, _, _ = signer._build_signature_base( + message, + covered_component_ids=signer._parse_covered_component_ids(covered_component_ids), + signature_params=dict(params), + ) except HTTPMessageSignaturesException as exc: raise ValueError(str(exc)) from exc + return str(base).encode("utf-8") + + +def _check_signable(message: Request | Response) -> None: + """Refuse a request the library can't resolve, and say how to fix its URL. + + The library parses the URL strictly (RFC 3986). A raw ``{``, ``}``, or + ``|`` in a query is not a URI character, so the caller percent-encodes + it, as ``httpx`` does for ``params=``. + """ + try: + HTTPSignatureComponentResolver(message) + except HTTPMessageSignaturesException as exc: + raise ValueError( + f"cannot sign this request: {exc}. Percent-encode every character RFC 3986 " + "doesn't allow in a URL, such as a raw '{', '}', or '|' in the query " + "(httpx params= encodes them)" + ) from exc def _timestamp(seconds: int) -> datetime: @@ -506,7 +404,6 @@ def sign( nonce: str | None, tag: str, covered_component_ids: Sequence[str], - resolver: type[ComponentResolver], append: bool = False, ) -> tuple[str, str]: """Sign ``message`` with ``HTTPMessageSigner.sign``; return the two fields. @@ -515,13 +412,9 @@ def sign( standard Base64; a legacy profile re-encodes it. With ``append``, a signature already on ``message`` stays, and this one is added beside it. """ - signer = ProfileSigner( - signature_algorithm=ALGORITHMS[alg], - key_resolver=_Key(private_key=private_key), - component_resolver_class=resolver, - ) + _check_signable(message) try: - signer.sign( + _signer(alg, private_key).sign( message, key_id=key_id, created=_timestamp(created), @@ -532,51 +425,53 @@ def sign( covered_component_ids=covered_component_ids, append_if_signature_exists=append, ) - except ComponentError as exc: - raise ValueError(exc.reason) from exc except HTTPMessageSignaturesException as exc: raise ValueError(str(exc)) from exc return message.headers["Signature-Input"], message.headers["Signature"] -def signature_base( +def prepare( message: Request | Response, *, alg: str, key_id: str, + label: str, created: int, expires: int | None, nonce: str | None, tag: str, covered_component_ids: Sequence[str], - resolver: type[ComponentResolver], -) -> tuple[bytes, str]: - """The base :func:`sign` would sign, and the serialized parameters. +) -> PreparedSignature: + """``HTTPMessageSigner.prepare``: what :func:`sign` would sign, unsigned. - Parameters are added in the order ``HTTPMessageSigner.sign`` adds them, - so a signature over this base is byte-identical to one :func:`sign` - produces. Used where the signature itself is awaited. + The signature over ``signature_base`` goes to :func:`apply`. Used where + the signature itself is awaited. """ - params: dict[str, Any] = OrderedDict() - params["created"] = created - params["keyid"] = key_id - params["alg"] = ALGORITHMS[alg].algorithm_id - if expires is not None: - params["expires"] = expires - if nonce: - params["nonce"] = nonce - params["tag"] = tag - handler = ProfileSigner( - signature_algorithm=ALGORITHMS[alg], - key_resolver=_Key(), - component_resolver_class=resolver, - ) + _check_signable(message) try: - return handler.base(message, covered_component_ids=covered_component_ids, params=params) - except ComponentError as exc: - raise ValueError(exc.reason) from exc + return _signer(alg).prepare( + message, + key_id=key_id, + created=_timestamp(created), + expires=_timestamp(expires) if expires is not None else None, + nonce=nonce, + label=label, + tag=tag, + covered_component_ids=covered_component_ids, + ) + except HTTPMessageSignaturesException as exc: + raise ValueError(str(exc)) from exc + + +def apply( + message: Request | Response, prepared: PreparedSignature, signature: bytes, *, alg: str +) -> tuple[str, str]: + """``HTTPMessageSigner.apply``: put ``signature`` on ``message``; return the two fields.""" + try: + _signer(alg).apply(message, prepared, signature) except HTTPMessageSignaturesException as exc: raise ValueError(str(exc)) from exc + return message.headers["Signature-Input"], message.headers["Signature"] class ProfileVerifier(HTTPMessageVerifier): @@ -614,7 +509,6 @@ def verify( tag: str, alg: str, public_key: Any, - resolver: type[ComponentResolver], window_checked: bool, parameters: Sequence[str] = (), ) -> Mapping[str, str]: @@ -623,7 +517,7 @@ def verify( ``message.headers`` must carry the ``Signature-Input`` and ``Signature`` fields to verify. ``alg`` and ``public_key`` are the profile's choice; the library confirms the signature names the same ``alg``. Raises - :class:`SignatureBaseError` when a component does not resolve and + :class:`SignatureBaseError` when the signature base can't be built and :class:`SignatureMismatchError` when the signature does not verify. ``window_checked`` must be true: :class:`ProfileVerifier` does not check @@ -631,46 +525,45 @@ def verify( """ if window_checked is not True: raise ValueError("check the signature window before verifying") + try: + # A key that doesn't fit the algorithm can't verify this signature; + # refusing it here leaves the library's errors to the base and the + # signature. + ALGORITHMS[alg](public_key=public_key) + except HTTPMessageSignaturesException as exc: + raise SignatureMismatchError(str(exc)) from exc verifier = ProfileVerifier( signature_algorithm=ALGORITHMS[alg], key_resolver=_Key(public_key=public_key), - component_resolver_class=resolver, ) - verifier.signature_metadata_parameters = ( - HTTPMessageVerifier.signature_metadata_parameters | set(parameters) + # The profile has already decided which parameters it accepts. + setattr( # a per-verifier value of the library class variable + verifier, + "signature_metadata_parameters", + HTTPMessageVerifier.signature_metadata_parameters | set(parameters), ) try: (result,) = verifier.verify(message, expect_tag=tag, expect_label=label) except InvalidSignature as exc: cause = exc.__cause__ - if isinstance(cause, ComponentError): - raise SignatureBaseError(cause.reason, status=cause.status, cause=cause) from exc - if cause is not None and not isinstance(cause, HTTPMessageSignaturesException): - # A crypto failure is cryptography's InvalidSignature. Anything - # else (an AdCP canonicalization error, say) came from the base. - from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature - - if not isinstance(cause, CryptoInvalidSignature): - raise SignatureBaseError(str(cause), cause=cause) from exc + from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature + + if cause is not None and not isinstance(cause, CryptoInvalidSignature): + # The library could not build the signature base. + raise SignatureBaseError(str(cause), cause=cause) from exc raise SignatureMismatchError("signature did not verify") from exc - except HTTPMessageSignaturesException as exc: - # The key does not fit the algorithm: it cannot verify this signature. - raise SignatureMismatchError(str(exc)) from exc return dict(result.covered_components) __all__ = [ "ALGORITHMS", - "AdcpComponentResolver", - "ComponentError", - "ComponentResolver", "DuplicateFieldError", - "ProfileSigner", "ProfileVerifier", "Request", "Response", "SignatureBaseError", "SignatureMismatchError", + "apply", "build_base", "component_identifier", "dictionary_has_parameter", @@ -678,10 +571,10 @@ def verify( "legacy_binary_tokens", "mixes_base64_alphabets", "parse_dictionary", + "prepare", "ser_item_or_inner_list", "serialize", "sign", - "signature_base", "translate_legacy_binary", "verify", ] diff --git a/src/adcp/signing/canonical.py b/src/adcp/signing/canonical.py index 0d6a22ecd..9f6c71905 100644 --- a/src/adcp/signing/canonical.py +++ b/src/adcp/signing/canonical.py @@ -10,9 +10,8 @@ RFC 9421 itself (the ``Signature-Input`` grammar and the signature base) comes from ``http-message-signatures`` and ``http-sf``; :func:`parse_signature_input_header` and :func:`build_signature_base` are -the public entry points onto them, and -:class:`adcp.signing._rfc9421.AdcpComponentResolver` plugs the -canonicalization below into the library. +the public entry points onto them, and :func:`signing_message` hands the +library a request whose URL is already the canonical target URI. """ from __future__ import annotations @@ -25,6 +24,7 @@ from adcp.signing._idna_canonicalize import canonicalize_host from adcp.signing._rfc9421 import ( + Request, component_identifier, is_inner_list, parse_dictionary, @@ -128,22 +128,31 @@ def build_signature_base( ``http-message-signatures`` builds the base: lines joined with a single LF, no trailing newline, components in ``Signature-Input`` order, then - ``@signature-params``. :class:`AdcpComponentResolver` supplies the AdCP - ``@target-uri`` and ``@authority`` canonicalization. The base comes from + ``@signature-params``, over :func:`signing_message`. The base comes from ``parsed.components`` and ``parsed.params`` (in order); ``raw_value`` is not consulted, so ``@signature-params`` is the RFC 8941 serialization. """ - from adcp.signing._rfc9421 import AdcpComponentResolver, Request, build_base + from adcp.signing._rfc9421 import build_base base = build_base( - Request(method=method, url=url, headers=dict(headers)), + signing_message(method, url, headers), covered_component_ids=[component_identifier((name, {})) for name in parsed.components], params=parsed.params, - resolver=AdcpComponentResolver, ) return base.decode("utf-8") +def signing_message(method: str, url: str, headers: Mapping[str, str]) -> Request: + """The request the AdCP profile signs: its URL is the canonical target URI. + + ``http-message-signatures`` derives ``@target-uri`` and ``@authority`` + from the URL it is given, so handing it the canonical target URI makes + them the AdCP values. Raises :class:`TargetUriMalformedError` for a URL + the profile rejects. + """ + return Request(method=method, url=canonicalize_target_uri(url), headers=dict(headers)) + + def canonicalize_target_uri(url: str) -> str: """Produce the `@target-uri` derived-component value per AdCP profile.""" parts = _split_or_reject(url) diff --git a/src/adcp/signing/signer.py b/src/adcp/signing/signer.py index cbf45cea4..c0e6818e4 100644 --- a/src/adcp/signing/signer.py +++ b/src/adcp/signing/signer.py @@ -28,7 +28,7 @@ from http_message_signatures.exceptions import HTTPMessageSignaturesException from adcp.signing import _rfc9421 -from adcp.signing.canonical import _lookup +from adcp.signing.canonical import _lookup, signing_message from adcp.signing.constants import ( DEFAULT_EXPIRES_IN_SECONDS, DEFAULT_TAG, @@ -142,7 +142,7 @@ def _prepare_request( outgoing_headers["Content-Digest"] = content_digest_value components.append("content-digest") return _PreparedRequest( - message=_rfc9421.Request(method=method, url=url, headers=outgoing_headers), + message=signing_message(method, url, outgoing_headers), covered_component_ids=tuple( _rfc9421.component_identifier((name, {})) for name in components ), @@ -278,7 +278,6 @@ def sign_request( nonce=prepared.nonce, tag=tag, covered_component_ids=prepared.covered_component_ids, - resolver=_rfc9421.AdcpComponentResolver, ) return _signed_headers( prepared, @@ -341,23 +340,24 @@ async def async_sign_request( tag=tag, label=label, ) - # The same base and parameters HTTPMessageSigner.sign produces, so the - # headers match sign_request byte for byte. - base, member = _rfc9421.signature_base( + # HTTPMessageSigner.prepare and apply, the library's two halves of sign, + # so the headers match sign_request byte for byte. + to_sign = _rfc9421.prepare( prepared.message, alg=alg, key_id=key_id, + label=label, created=prepared.created, expires=prepared.expires, nonce=prepared.nonce, tag=tag, covered_component_ids=prepared.covered_component_ids, - resolver=_rfc9421.AdcpComponentResolver, ) - signature = await provider.sign(base) + signature = await provider.sign(to_sign.signature_base) + signature_input, _ = _rfc9421.apply(prepared.message, to_sign, signature, alg=alg) return _signed_headers( prepared, - signature_input=_rfc9421.signature_input_field(label, member), + signature_input=signature_input, signature=signature, label=label, signing_profile_version=signing_profile_version, diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 9c6fbc16f..cd1323391 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -27,6 +27,7 @@ TargetUriMalformedError, _lookup, parse_signature_input_header, + signing_message, ) from adcp.signing.constants import ( ADCP_USE_REQUEST, @@ -449,15 +450,29 @@ def verify_request_signature( # selected label is handed over alone, re-serialized, with its signature # in RFC 8941 form, so the other labels the profile ignores (and the # 3.0/3.1 Base64URL spelling) never reach the library. - message = _rfc9421.Request( - method=method, - url=url, - headers={ - **{k: v for k, v in headers.items() if k.lower() not in _SIGNATURE_FIELDS}, - "Signature-Input": _rfc9421.signature_input_field(options.label, parsed.raw_value), - "Signature": format_signature_header(sig_bytes, label=options.label), - }, - ) + try: + message = signing_message( + method, + url, + { + **{k: v for k, v in headers.items() if k.lower() not in _SIGNATURE_FIELDS}, + "Signature-Input": _rfc9421.signature_input_field(options.label, parsed.raw_value), + "Signature": format_signature_header(sig_bytes, label=options.label), + }, + ) + except TargetUriMalformedError as exc: + # A malformed authority keeps its own code (step 6 of the + # canonicalization rules), not request_signature_header_malformed. + # test_target_uri_malformed_boundary.py guards the distinction. + raise SignatureVerificationError( + exc.code, step=6, message=f"signature base construction failed: {exc}" + ) from exc + except ValueError as exc: + raise SignatureVerificationError( + REQUEST_SIGNATURE_HEADER_MALFORMED, + step=6, + message=f"signature base construction failed: {exc}", + ) from exc try: _rfc9421.verify( message, @@ -465,20 +480,10 @@ def verify_request_signature( tag=str(parsed.params["tag"]), alg=alg, public_key=public_key, - resolver=_rfc9421.AdcpComponentResolver, window_checked=True, # step 5 parameters=tuple(parsed.params), ) except _rfc9421.SignatureBaseError as exc: - if isinstance(exc.cause, TargetUriMalformedError): - # A malformed authority keeps its own code (step 6 of the - # canonicalization rules), not request_signature_header_malformed. - # test_target_uri_malformed_boundary.py guards the distinction. - raise SignatureVerificationError( - exc.cause.code, - step=6, - message=f"signature base construction failed: {exc.cause}", - ) from exc raise SignatureVerificationError( REQUEST_SIGNATURE_HEADER_MALFORMED, step=6, diff --git a/src/adcp/signing/wba.py b/src/adcp/signing/wba.py index dfaa25ece..acb67fbe7 100644 --- a/src/adcp/signing/wba.py +++ b/src/adcp/signing/wba.py @@ -364,30 +364,6 @@ def _received_authority(url: str) -> str: return authority -class _WbaComponentResolver(_rfc9421.ComponentResolver): - """Web Bot Auth components: the target URI as received, not canonicalized. - - ``@target-uri`` is the URI exactly as sent or received (RFC 9421 - §2.2.2), percent-encoding included; ``@authority`` is its normalized - authority. The AdCP origin canonicalization identifies the signer only. - """ - - def get_target_uri(self) -> str: - url = self.url - if any(ord(c) <= 0x20 or ord(c) == 0x7F for c in url) or not url.isascii(): - raise _rfc9421.ComponentError("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 _rfc9421.ComponentError("request URL is not an absolute target URI") - return url - - def get_authority(self) -> str: - try: - return _received_authority(self.url) - except WbaVerificationError as exc: - raise _rfc9421.ComponentError(exc.reason) from exc - - def _signature_base( label: str, method: str, url: str, headers: Mapping[str, str], *, response: bool = False ) -> bytes: @@ -410,7 +386,6 @@ def _signature_base( message, covered_component_ids=[_rfc9421.component_identifier(item) for item in items], params=params, - resolver=_WbaComponentResolver, ) @@ -464,7 +439,6 @@ def _verify_crypto( tag=str(params["tag"]), alg=alg, public_key=public_key, - resolver=_WbaComponentResolver, window_checked=True, # every caller ran _window first parameters=tuple(params), ) @@ -549,7 +523,6 @@ def sign_wba_request( nonce=nonce, tag=WBA_TAG, covered_component_ids=covered, - resolver=_WbaComponentResolver, ) outgoing["Signature-Input"] = signature_input outgoing["Signature"] = signature @@ -742,7 +715,6 @@ def sign_key_directory( nonce=None, tag=DIRECTORY_TAG, covered_component_ids=['"@authority";req', '"content-digest"'], - resolver=_WbaComponentResolver, append=True, ) headers.update({"Signature-Input": signature_input, "Signature": signature}) diff --git a/tests/conformance/signing/test_rfc9421_library_contract.py b/tests/conformance/signing/test_rfc9421_library_contract.py index 8c82224e9..3e3ac0eaa 100644 --- a/tests/conformance/signing/test_rfc9421_library_contract.py +++ b/tests/conformance/signing/test_rfc9421_library_contract.py @@ -1,12 +1,13 @@ """The http-message-signatures and http-sf shapes adcp.signing._rfc9421 relies on. The SDK takes RFC 9421 from ``http-message-signatures`` and RFC 8941 from -``http-sf``. It plugs in through public extension points (the -``component_resolver_class``, ``validate_created_and_expires``, -``signature_metadata_parameters``) and two protected subclass hooks -(``_build_signature_base``, ``_parse_covered_component_ids``). A library -release that moves any of them fails here, before it fails a profile test -with a confusing error code. +``http-sf``. It uses the library's resolver as it ships (``;key`` and +``;req`` included), its public extension points +(``validate_created_and_expires``, ``signature_metadata_parameters``, +``prepare`` and ``apply``), and, for the diagnostic base builder, two +protected hooks (``_build_signature_base``, ``_parse_covered_component_ids``). +A library release that moves any of them fails here, before it fails a +profile test with a confusing error code. """ from __future__ import annotations @@ -19,8 +20,6 @@ from http_message_signatures import ( HTTPMessageSigner, HTTPMessageVerifier, - HTTPSignatureComponentResolver, - InvalidSignature, ) from http_message_signatures.signatures import HTTPSignatureHandler @@ -50,65 +49,27 @@ def test_protected_hooks_keep_their_signatures(): } -def test_resolver_receives_nodes_with_value_and_params(): - seen = [] - - class Recording(_rfc9421.ComponentResolver): - def resolve(self, component_node): - seen.append((component_node.value, dict(component_node.params))) - return super().resolve(component_node) - +def test_library_resolves_key_and_req(): + """RFC 9421 sections 2.1.2 and 2.4, which the profiles cover, come from the library.""" message = _rfc9421.Request( "GET", "https://example.com/", {"Example-Dict": "a=1, b=2;x=1;y=2, c=(a b c)"} ) base = _rfc9421.build_base( - message, - covered_component_ids=['"example-dict";key="b"', '"@authority"'], - params={"created": 1}, - resolver=Recording, + message, covered_component_ids=['"example-dict";key="b"'], params={"created": 1} ) - assert seen == [("example-dict", {"key": "b"}), ("@authority", {})] - # RFC 9421 section 2.1.2: the member value with its parameters. + # The member value with its parameters. assert base.decode().splitlines()[0] == '"example-dict";key="b": 2;x=1;y=2' - - -def test_sign_emits_the_parameter_order_signature_base_reproduces(): - key = ed25519.Ed25519PrivateKey.generate() - covered = ['"@method"', '"@authority"'] - signed = _rfc9421.Request("POST", "https://example.com/p") - signature_input, signature = _rfc9421.sign( - signed, - alg="ed25519", - private_key=key, - key_id="k", - label="sig1", - created=10, - expires=20, - nonce="n", - tag="t", - covered_component_ids=covered, - resolver=_rfc9421.ComponentResolver, - ) - assert signature_input == ( - 'sig1=("@method" "@authority");created=10;keyid="k";alg="ed25519";' - 'expires=20;nonce="n";tag="t"' - ) - base, member = _rfc9421.signature_base( - _rfc9421.Request("POST", "https://example.com/p"), - alg="ed25519", - key_id="k", - created=10, - expires=20, - nonce="n", - tag="t", - covered_component_ids=covered, - resolver=_rfc9421.ComponentResolver, + response = _rfc9421.Response(200, _rfc9421.Request("GET", "https://Example.com/p")) + base = _rfc9421.build_base( + response, covered_component_ids=['"@authority";req'], params={"created": 1} ) - assert signature_input == f"sig1={member}" - assert _rfc9421.parse_dictionary(signature)["sig1"][0] == key.sign(base) + assert base.decode().splitlines()[0] == '"@authority";req: example.com' + for identifier in ('"@authority";req=?0', '"@authority"'): + with pytest.raises(ValueError): + _rfc9421.build_base(response, covered_component_ids=[identifier], params={}) -def test_verify_chains_the_component_error_and_the_crypto_error(): +def test_verify_tells_a_base_failure_from_a_bad_signature(): key = ed25519.Ed25519PrivateKey.generate() message = _rfc9421.Request("POST", "https://example.com/p", {"x-a": "1"}) signature_input, signature = _rfc9421.sign( @@ -122,39 +83,26 @@ def test_verify_chains_the_component_error_and_the_crypto_error(): nonce="n", tag="t", covered_component_ids=['"x-a"'], - resolver=_rfc9421.ComponentResolver, ) fields = {"Signature-Input": signature_input, "Signature": signature} - verifier = _rfc9421.ProfileVerifier( - signature_algorithm=_rfc9421.ALGORITHMS["ed25519"], - key_resolver=_rfc9421._Key(public_key=key.public_key()), - component_resolver_class=_rfc9421.ComponentResolver, - ) - with pytest.raises(InvalidSignature) as missing: - verifier.verify(_rfc9421.Request("POST", "https://example.com/p", fields), expect_tag="t") - assert isinstance(missing.value.__cause__, _rfc9421.ComponentError) - with pytest.raises(InvalidSignature) as tampered: - verifier.verify( - _rfc9421.Request("POST", "https://example.com/p", {**fields, "x-a": "2"}), - expect_tag="t", + + def verify(headers): + return _rfc9421.verify( + _rfc9421.Request("POST", "https://example.com/p", headers), + label="sig1", + tag="t", + alg="ed25519", + public_key=key.public_key(), + window_checked=True, + parameters=("created", "expires", "keyid", "alg", "nonce", "tag"), ) - from cryptography.exceptions import InvalidSignature as CryptoInvalidSignature - assert isinstance(tampered.value.__cause__, CryptoInvalidSignature) + with pytest.raises(_rfc9421.SignatureBaseError): + verify(fields) + with pytest.raises(_rfc9421.SignatureMismatchError): + verify({**fields, "x-a": "2"}) # The library's own window check is off: the profile checks its window. - verifier.verify( - _rfc9421.Request("POST", "https://example.com/p", {**fields, "x-a": "1"}), expect_tag="t" - ) - - -def test_library_resolver_still_lacks_key_and_req(): - """pyauth/http-message-signatures#29: drop the key/req branches once fixed.""" - resolver = HTTPSignatureComponentResolver( - _rfc9421.Request("GET", "https://example.com/", {"d": "a=1, b=2"}) - ) - node = HTTPMessageSigner._parse_covered_component_ids(None, ['"d";key="a"'])[0] - if resolver.resolve(node) == "1": # pragma: no cover - the upstream fix landed - pytest.fail("the library now resolves ;key; remove the ComponentResolver branch") + assert verify({**fields, "x-a": "1"})['"x-a"'] == "1" def test_http_sf_reports_every_duplicate(): @@ -189,25 +137,6 @@ def test_http_sf_primitives_the_profile_selection_walk_uses(): ) -def test_req_must_be_a_true_flag(): - response = _rfc9421.Response(200, _rfc9421.Request("GET", "https://example.com/")) - for identifier in ('"@authority";req=?0', '"@authority";req=1'): - with pytest.raises(ValueError, match="boolean flag"): - _rfc9421.build_base( - response, - covered_component_ids=[identifier], - params={}, - resolver=_rfc9421.ComponentResolver, - ) - with pytest.raises(ValueError, match="only applies to a response"): - _rfc9421.build_base( - _rfc9421.Request("GET", "https://example.com/"), - covered_component_ids=['"@authority";req'], - params={}, - resolver=_rfc9421.ComponentResolver, - ) - - def test_first_spelling_of_a_header_wins_as_in_lookup(): from adcp.signing.canonical import _lookup @@ -225,6 +154,37 @@ def test_verify_requires_the_caller_to_have_checked_the_window(): tag="t", alg="ed25519", public_key=None, - resolver=_rfc9421.ComponentResolver, window_checked=False, ) + + +@pytest.mark.parametrize("query", ["a={b}", "a=b|c"]) +def test_a_url_with_a_raw_non_uri_character_is_refused_with_the_fix(query): + """``http-message-signatures`` parses the URL per RFC 3986 and refuses ``{``, ``}``, ``|``. + + Both signers say to percent-encode the URL, as ``httpx`` does for ``params=``. + """ + from adcp.signing import sign_request, sign_wba_request + + key = ed25519.Ed25519PrivateKey.generate() + url = f"https://seller.example.com/mcp?{query}" + with pytest.raises(ValueError, match="Percent-encode"): + sign_request( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=b"{}", + private_key=key, + key_id="k", + alg="ed25519", + signing_profile_version="3.2", + ) + with pytest.raises(ValueError, match="Percent-encode"): + sign_wba_request( + method="GET", + url=url, + headers={}, + body=b"", + private_key=key, + agent_origin="https://buyer.example.com", + ) diff --git a/tests/conformance/signing/test_verifier_behaviors.py b/tests/conformance/signing/test_verifier_behaviors.py index 8c46fea9f..2fd18054a 100644 --- a/tests/conformance/signing/test_verifier_behaviors.py +++ b/tests/conformance/signing/test_verifier_behaviors.py @@ -77,35 +77,61 @@ def _options( # ---- 1e: downstream ValueError/KeyError wrapping ---- -def test_unsupported_derived_component_wraps_as_header_malformed() -> None: - # Craft Signature-Input declaring @path, which the AdCP profile does not - # support — build_signature_base raises ValueError. The verifier must wrap - # that as REQUEST_SIGNATURE_HEADER_MALFORMED rather than bubbling it up. +def test_an_extra_covered_derived_component_verifies() -> None: + """A signature may cover a derived component beyond the required three. + + The AdCP 3.2 checklist (security.mdx step 6) requires ``@method``, + ``@target-uri``, and ``@authority``, plus the body components, and + refuses nothing extra. ``http-message-signatures`` resolves ``@path``, + so a real signature that covers it verifies. + """ + from datetime import datetime, timezone + + from http_message_signatures import HTTPMessageSigner, HTTPSignatureKeyResolver + from http_message_signatures.algorithms import ED25519 + + from adcp.signing.canonical import signing_message + from adcp.signing.digest import compute_content_digest_sha256 + + class Key(HTTPSignatureKeyResolver): + def resolve_private_key(self, key_id): + return private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only") + url = "https://seller.example.com/adcp/create_media_buy" created = 1776520800 - expires = created + 300 - sig_input = ( - '("@method" "@target-uri" "@authority" "@path");' - f"created={created};expires={expires};" - 'nonce="a";keyid="test-ed25519-2026";alg="ed25519";' - 'tag="adcp/request-signing/v1"' - ) - # No Content-Type header — @path is the only novel component so it reaches - # build_signature_base (step 6) before component-set checks short-circuit. + body = b"{}" headers = { - "Signature-Input": f"sig1={sig_input}", - "Signature": "sig1=:AAAA:", + "Content-Type": "application/json", + "Content-Digest": compute_content_digest_sha256(body), } - with pytest.raises(SignatureVerificationError) as exc: - verify_request_signature( - method="POST", - url=url, - headers=headers, - body=b"{}", - options=_options(), - ) - assert exc.value.code == REQUEST_SIGNATURE_HEADER_MALFORMED - assert exc.value.step == 6 + message = signing_message("POST", url, headers) + HTTPMessageSigner(signature_algorithm=ED25519, key_resolver=Key()).sign( + message, + key_id="test-ed25519-2026", + created=datetime.fromtimestamp(created, timezone.utc), + expires=datetime.fromtimestamp(created + 300, timezone.utc), + nonce="KXYnfEfJ0PBRZXQyVXfVQA", + label="sig1", + tag="adcp/request-signing/v1", + covered_component_ids=( + "@method", + "@target-uri", + "@authority", + "@path", + "content-type", + "content-digest", + ), + ) + assert '"@path"' in message.headers["Signature-Input"] + + signer = verify_request_signature( + method="POST", + url=url, + headers={**headers, **{k: message.headers[k] for k in ("Signature-Input", "Signature")}}, + body=body, + options=_options(now=created), + ) + assert signer.key_id == "test-ed25519-2026" def test_missing_covered_header_wraps_as_header_malformed() -> None: diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index 4ca166a6e..df8326d7c 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -606,7 +606,9 @@ async def fetch(url, *, allow_private=False): async def test_signature_without_alg_is_refused(monkeypatch): """Every signature names ``alg``, and it must match the key (Profile rules).""" - sign = _rfc9421.ProfileSigner.sign + from http_message_signatures import HTTPMessageSigner + + sign = HTTPMessageSigner.sign def sign_without_alg(self, message, **kwargs): return sign(self, message, **kwargs, include_alg=False) @@ -617,7 +619,7 @@ def sign_without_alg(self, message, **kwargs): ) headers = {"Content-Type": "application/json"} with monkeypatch.context() as patched: - patched.setattr(_rfc9421.ProfileSigner, "sign", sign_without_alg) + patched.setattr(HTTPMessageSigner, "sign", sign_without_alg) headers.update( sign_wba_request( method="POST", @@ -779,7 +781,6 @@ def _sign_vector_request(*, nonce="A" * 86, signature_agent=None): signature. """ from adcp.signing import _rfc9421 - from adcp.signing.wba import _WbaComponentResolver key = KEYS["6WsKMCObba49V3uN0vIENnk1BKAk7c9zjBJcWKyQ9_I"] private = private_key_from_jwk(key, d_field="_private_d_for_test_only") @@ -808,7 +809,6 @@ def _sign_vector_request(*, nonce="A" * 86, signature_agent=None): '"content-digest"', '"signature-agent";key="sig1"', ], - resolver=_WbaComponentResolver, ) headers.update({"Signature-Input": signature_input, "Signature": signature}) diff --git a/tests/test_identity_3_3_alignment.py b/tests/test_identity_3_3_alignment.py index cbfca604e..9d2bf5230 100644 --- a/tests/test_identity_3_3_alignment.py +++ b/tests/test_identity_3_3_alignment.py @@ -69,7 +69,6 @@ OriginRevocationChecker, WbaDiscoveryError, WbaVerificationError, - _WbaComponentResolver, sign_key_directory, verify_directory_response, verify_wba_request, @@ -777,7 +776,6 @@ def _directory_signed_by( HTTPMessageSigner( signature_algorithm=algorithms[jwk["kty"]], key_resolver=_rfc9421._Key(private_key=private_key), - component_resolver_class=_WbaComponentResolver, ).sign( message, key_id=jwk["kid"], @@ -916,16 +914,27 @@ def test_every_directory_signature_member_is_a_byte_sequence(): def test_directory_signatures_require_the_exact_component_identifiers(): + from dataclasses import replace + signed = ed25519.Ed25519PrivateKey.generate() - assert verify_directory_response(ORIGIN, _directory_signed_by([signed]), now=1001) + directory = _directory_signed_by([signed]) + assert verify_directory_response(ORIGIN, directory, now=1001) + covered = '("@authority";req "content-digest")' + assert covered in directory.headers["Signature-Input"] + # A bare "@authority" in a response signature is not RFC 9421 (section + # 2.4), so no library signs it; the refusal comes before verification, + # so the Signature-Input alone is changed. for components in ( - ['"@authority"', '"content-digest"'], - ['"@authority"'], - ['"@authority";req', '"content-digest";key="sha-256"'], + '("@authority" "content-digest")', + '("@authority")', + '("@authority";req "content-digest";key="sha-256")', ): + changed = directory.headers["Signature-Input"].replace(covered, components) with pytest.raises(WbaVerificationError, match="bind authority and digest"): verify_directory_response( - ORIGIN, _directory_signed_by([signed], components=components), now=1001 + ORIGIN, + replace(directory, headers={**directory.headers, "Signature-Input": changed}), + now=1001, ) From 4de1237d9caa6188d88f558a8d16ad55b7c6de59 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 02:40:58 -0400 Subject: [PATCH 44/46] test(signing): grade against #8298 at 306cdfa6 and the #7894 vectors at e5929470 ADCP_REF moves to 306cdfa6, the #8298 head, where the harness grades an adapter crash as a failure, never as a refusal. The wba-profile vectors move to e5929470, the #7894 head: scenarios.json adds D14c (a private member spelled in upper case) and equals the harness's copy at 306cdfa6. The request and directory vectors, keys.json, and the upstream README are byte-identical to f311e1b5. With http-message-signatures 2.1.0 the adapter passes 64 of 64 checks, with no adapter crash. --- .github/workflows/ci.yml | 2 +- docs/agent-identity-3-3-migration.md | 2 +- tests/conformance/signing/test_wba_profile.py | 2 +- .../conformance/vectors/wba-profile/README.md | 4 ++-- .../vectors/wba-profile/UPSTREAM.json | 6 +++--- .../vectors/wba-profile/scenarios.json | 20 +++++++++++++++++++ 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c09cbeb5..4c982f197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -382,7 +382,7 @@ jobs: # adcontextprotocol/adcp commit that carries tests/wba-interop and the wba-profile # vectors: the head of adcontextprotocol/adcp#8298. Pin its merge commit once it # merges, and a release tag once one carries the harness. - ADCP_REF: "ef966de19affc8b36a28f2fbc7c508050ce92d7b" + ADCP_REF: "306cdfa6e565725e1a122838eafbcc980707306b" steps: - uses: actions/checkout@v6 with: diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 1e7918aa5..fd10e7700 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -1,6 +1,6 @@ # Experimental AdCP 3.3 agent identity -This SDK supports an opt-in preview of issue [#1500](https://github.com/adcontextprotocol/adcp-client-python/issues/1500). The Web Bot Auth wire profile follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `f311e1b5` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both may change before the protocol release. The stable wire pin and `COMPATIBLE_ADCP_VERSIONS` do not advertise 3.3 yet. +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 follows `wba-profile.mdx` in adcontextprotocol/adcp#7894 at `e5929470` (vectors in `tests/conformance/vectors/wba-profile/`, provenance in its `UPSTREAM.json`); the identity schemas are pinned to the commits in `src/adcp/signing/_identity_schemas/UPSTREAM.json`. Both 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. diff --git a/tests/conformance/signing/test_wba_profile.py b/tests/conformance/signing/test_wba_profile.py index df8326d7c..61f93d77d 100644 --- a/tests/conformance/signing/test_wba_profile.py +++ b/tests/conformance/signing/test_wba_profile.py @@ -59,7 +59,7 @@ async def resolve(keyid, *, signer_origin=None): def test_vendored_vectors_match_recorded_provenance(): upstream = json.loads((ROOT / "UPSTREAM.json").read_text()) - assert upstream["commit"] == "f311e1b567c55c1d0689b2b7fd9b4a35e9ba91fe" + assert upstream["commit"] == "e59294701d733b0ea2a004a2f0c091c8eccdcaae" vendored = { str(path.relative_to(ROOT)) for path in ROOT.rglob("*") diff --git a/tests/conformance/vectors/wba-profile/README.md b/tests/conformance/vectors/wba-profile/README.md index ec3d5585e..5b412ce90 100644 --- a/tests/conformance/vectors/wba-profile/README.md +++ b/tests/conformance/vectors/wba-profile/README.md @@ -1,8 +1,8 @@ # Web Bot Auth profile vectors Copied without modification from adcontextprotocol/adcp PR #7894, commit -`f311e1b567c55c1d0689b2b7fd9b4a35e9ba91fe`, -`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-18. The +`e59294701d733b0ea2a004a2f0c091c8eccdcaae`, +`static/compliance/source/test-vectors/wba-profile/`, on 2026-10-19. The upstream `README.md` is vendored as `UPSTREAM-README.md`. `UPSTREAM.json` records each file's SHA-256, and `tests/conformance/signing/test_wba_profile.py` checks them. diff --git a/tests/conformance/vectors/wba-profile/UPSTREAM.json b/tests/conformance/vectors/wba-profile/UPSTREAM.json index 20affba54..903e895d1 100644 --- a/tests/conformance/vectors/wba-profile/UPSTREAM.json +++ b/tests/conformance/vectors/wba-profile/UPSTREAM.json @@ -1,9 +1,9 @@ { "repository": "adcontextprotocol/adcp", "pull_request": 7894, - "commit": "f311e1b567c55c1d0689b2b7fd9b4a35e9ba91fe", + "commit": "e59294701d733b0ea2a004a2f0c091c8eccdcaae", "path": "static/compliance/source/test-vectors/wba-profile/", - "vendored_on": "2026-10-18", + "vendored_on": "2026-10-19", "renamed": { "README.md": "UPSTREAM-README.md" }, @@ -18,6 +18,6 @@ "negative/006-two-signatures.json": "87cb93e1f9bc4fba2eadb0df7832d51c9edf2d0add02aab5cee8265b9d7f1661", "positive/001-single-signer-request.json": "213b69f5f029c6da37abbc4dafff0f89e7604c5a6a4acca59b8c9df4a3d88e25", "positive/002-signed-directory-response.json": "b07b0a947d943225c29fcb819008d080ca83f209d56be785a1a7b15f15e82dbe", - "scenarios.json": "1e35ed32fc68801fc72c9e923eb7f7dbf9ba43745256ea360a548147a7feb47a" + "scenarios.json": "0327f40cf31de2dc6cf975d9643a999aee68a225817dd0d409b81a6f92a868e2" } } diff --git a/tests/conformance/vectors/wba-profile/scenarios.json b/tests/conformance/vectors/wba-profile/scenarios.json index c2beb4b31..7c41dd83c 100644 --- a/tests/conformance/vectors/wba-profile/scenarios.json +++ b/tests/conformance/vectors/wba-profile/scenarios.json @@ -780,6 +780,26 @@ }, "tier": "adcp", "basis": "Profile: refuse a directory in which any key carries a private member; WBA section 5.5.1: a verifier SHOULD reject malformed entries" + }, + { + "id": "D14c", + "name": "Extra P-256 key that carries its private scalar as an upper-case D", + "unsigned_keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "ayhQePn14MyJGqrKHsp_A06Z0N6IZoE0-7wbn66qLLc", + "y": "k98dk4yymVze93JE_EpboBD6oaZbcII4Lvf_Oqu8kII", + "kid": "4rrLPtiRac4zmWwTBQeJlzgNyb5jai8HSOTJONOIKFY", + "use": "sig", + "D": "IyiPVggq91xZTs3CxpETaAbK6Qca0DuuD9-DZx7ueHs" + } + ], + "expected": { + "success": false + }, + "tier": "adcp", + "basis": "Profile: private member names compare without regard to case; WBA section 5.5.1: a verifier SHOULD reject malformed entries" } ] } From 275a0679242d7f748d44e3bc78c8c92a282f4ae9 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 03:12:28 -0400 Subject: [PATCH 45/46] test(reporting): load the filesystem row provider from @adcp/reporting-storage Ports the driver change from #1521 (bde770b76) on main. The JS SDK moved createFilesystemReportingRowObjectProviderV1 out of the ledger package, so the row-storage interop lane fails on any branch cut before #1521. The same change is already on main and next, so the sync merges cleanly. --- tests/interop/reporting_row_storage_js_driver.cjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/interop/reporting_row_storage_js_driver.cjs b/tests/interop/reporting_row_storage_js_driver.cjs index 3d78f7951..1e56f08e9 100644 --- a/tests/interop/reporting_row_storage_js_driver.cjs +++ b/tests/interop/reporting_row_storage_js_driver.cjs @@ -2,7 +2,7 @@ /** * JavaScript half of the cross-SDK row storage interop test. * - * Drives the shipped `@adcp/sdk` row storage (`ReportingRowStorageV1`) against a + * Drives the shipped JavaScript row storage (`ReportingRowStorageV1`) against a * PostgreSQL schema and a filesystem bucket root shared with the Python SDK. * * node reporting_row_storage_js_driver.cjs write --out js-written.json @@ -36,6 +36,11 @@ const sdkEntry = fs.existsSync(path.join(sdkDir, 'dist/lib/reporting/ledger/inde ? path.join(sdkDir, 'dist/lib/reporting/ledger/index.js') : require.resolve('@adcp/sdk/reporting/ledger', { paths: [sdkDir] }); const ledger = require(sdkEntry); +// SDK 16 moved the storage implementations into their own workspace package. +const rowStorage = typeof ledger.ReportingRowStorageV1 === 'function' + && typeof ledger.createFilesystemReportingRowObjectProviderV1 === 'function' + ? ledger + : requireFromSdk('@adcp/reporting-storage'); const canonicalize = fs.existsSync(path.join(sdkDir, 'dist/lib/utils/jcs.js')) ? require(path.join(sdkDir, 'dist/lib/utils/jcs.js')).canonicalize : requireFromSdk('@adcp/sdk').canonicalize; @@ -71,7 +76,7 @@ function rowsOnly(rows) { async function main() { const pool = new Pool({ connectionString: url, options: `-c search_path="${schema}"` }); - const storage = new ledger.ReportingRowStorageV1(pool, { + const storage = new rowStorage.ReportingRowStorageV1(pool, { deploymentNamespace: namespace, bindings: { inline: { kind: 'postgres' }, @@ -83,7 +88,7 @@ async function main() { compression: 'gzip', }, }, - providers: { filesystem: ledger.createFilesystemReportingRowObjectProviderV1({ roots: { reports: root } }) }, + providers: { filesystem: rowStorage.createFilesystemReportingRowObjectProviderV1({ roots: { reports: root } }) }, }); await storage.ready(); try { From e5c82074b30d997ee59b43002d22d11c632e3d51 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Mon, 19 Oct 2026 14:06:21 -0400 Subject: [PATCH 46/46] fix(build): keep next's mypy TypeForm setting and 3.2.4 compliance data alongside the 2.1 pin --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2a9e6d18d..e31aa4fa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -288,6 +288,10 @@ adcp = [ "_compliance/3.2.1/**/*.jsonl", "_compliance/3.2.1/**/*.yaml", "_compliance/3.2.1/**/*.md", + "_compliance/3.2.4/**/*.json", + "_compliance/3.2.4/**/*.jsonl", + "_compliance/3.2.4/**/*.yaml", + "_compliance/3.2.4/**/*.md", # Vendored canonical-formats reference fixtures (14 v2 products + # 50-entry v1 catalog) so :mod:`adcp.canonical_formats.fixtures` # can serve them to adopter test suites without forcing each @@ -345,6 +349,10 @@ ignore = [ ] [tool.mypy] +enable_incomplete_feature = ["TypeForm"] +# Mypy 1.20.2 drops is_type_form in its fixed-format cache. The JSON +# cache preserves Pydantic 2.14 adapter signatures across incremental checks. +fixed_format_cache = false python_version = "3.10" strict = true warn_return_any = true