Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 27 additions & 13 deletions docs/agent-identity-3-3-migration.md

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
110 changes: 83 additions & 27 deletions src/adcp/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
# 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, TypeError, ValueError) as exc:
raise ValueError(f"governance signing key cannot sign {alg}: {exc}") from exc


async def verify_governance_authorization(
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 2 additions & 9 deletions src/adcp/server/signed_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading