Repository navigation
feat(identity): verify JWS artifacts on the issuer's Web Bot Auth key directory - #1543
Conversation
* chore(main): release 9.0.0-beta.4 * chore: normalize prerelease version to PEP 440 --------- Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
…1535) Record the branch model from adcp-client-python#1525 in RELEASING.md: which branch carries which spec line, how fixes move between them (backports, regular main to next merges, forward-port and backport labels), how next cuts 10.0.0-alpha.N pre-releases from its own release-please setup, the one-time procedure that keeps #1502 on next after main reverts it, and how next merges into main at ratification. CONTRIBUTING.md points feature branches at the right base. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…signing tests CI skips (#1539) * test(webhooks): pass operation_id to send_mcp in the webhook sender tests operation_id became a required keyword of WebhookSender.send_mcp in #1066. Twenty-five calls in test_webhook_sender_e2e.py, test_webhook_sender_alt_auth.py, and test_webhook_sender_from_pem.py still omitted it, so 24 tests raised TypeError before they reached the behavior they test. CI doesn't install fastapi, so the modules skip there and nothing reported it (#1524). Each call now passes operation_id. * test(webhooks): give the proxy-env test's fake client the stream method the sender calls Since #1066 the sender posts through client.stream, which bounds the response body it keeps. The fake httpx.AsyncClient in test_owned_client_ignores_https_proxy_env still had only post, so the test failed with AttributeError once the operation_id fix let it run. The fake now yields its response from stream. * test(webhooks): retry with the first delivery's timestamp in the dedupe test An idempotency key binds the payload. test_retry_with_same_idempotency_key_dedupes called send_mcp twice with the same key and no timestamp, so each call stamped its own time, the second body differed, and the receiver answered 409 instead of a duplicate 200. send_mcp's docstring names the conflict. Both calls now pass one timestamp, so the retry repeats the first body and the receiver dedupes it. * test(signing): sign the digest-off FastAPI case with the 3.1 profile Since #1038 the 3.2 signer refuses a non-empty body without content-digest, as AdCP 3.2 request signing requires. The either-False case of test_signed_request_verifies_end_to_end asked the 3.2 signer for exactly that and raised ValueError before it reached the verifier. The case now signs with the 3.1 profile, where content-digest is optional, which is what the either policy accepts; the required case still signs with 3.2. * test(webhooks): re-sign the retry in the HTTP dedupe test test_duplicate_retry_dedupes_over_real_http posted the same body twice with the same Signature and nonce, so the receiver refused the second POST with 401 webhook_signature_replayed before it reached deduplication. A sender's retry repeats the body under a fresh signature, as WebhookSender.resend does. Each POST now carries its own signature, and the second one gets 200 with duplicate=true. * ci: install fastapi and svix with the dev extra Every CI job installs the package with .[dev] or .[dev,pg], and neither extra resolved fastapi or svix. Five signing test modules skip whole on pytest.importorskip("fastapi"), and test_standard_webhooks.py skips its two svix interop tests, so CI never ran them and their failures went unseen (#1524). The dev extra now carries both packages, so each job that runs the signing suite runs these files too. Closes #1524 * 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.5) requires, for authorized_commitment, authorized_task, and authorized_payload_hash, "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". verify_governance_authorization 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, a marker with no crit entry, or a false marker alone. The check now requires the three to be present together, with the marker true. test_critical_marker_crit_entry_and_claim_appear_together re-signs the accepted vector with each header, so only the crit check can refuse it; it failed before this change. * fix(governance): answer a non-string token alg with governance_token_invalid verify_governance_authorization 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_alg_is_an_invalid_token failed before this change. * fix(signing): decode base64url strictly JWS and JWK members are base64url: the RFC 4648 section 5 alphabet with the padding omitted (RFC 7515 section 2). RFC 4648 section 3.3 says implementations MUST reject encoded data that contains characters outside the base alphabet. b64url_decode used the standard library's lenient decoder, which accepted padding and the standard alphabet's + and /, dropped other characters, and ignored trailing bits. Verifier step 8 decodes the signer's JWK through public_key_from_jwk, so the request and webhook verifiers accepted a JWK whose x member had "!!" inserted. b64url_decode now refuses padding, + and /, and decodes the rest with base64.b64decode(altchars=b"-_", validate=True), which refuses any other character. It also refuses non-zero trailing bits (RFC 4648 section 3.5), so each value has the one spelling b64url_encode produces. Every caller already handles the ValueError it raises. test_jwk_members_are_strict_base64url failed before this change.
Brings #1539 (governance crit-marker and non-string alg checks, the #1524 signing test fixes, fastapi and svix in the dev extra) into next. Conflicts: - src/adcp/governance.py: keep next's critical_claims_failure, which already requires each marker, crit entry and claim together (#1537), and take main's refusal of a non-string alg. - src/adcp/signing/crypto.py: keep next's joserfc-based strict b64url_decode, which already refuses padding, other alphabets and non-zero trailing bits. - tests/conformance/signing/test_crypto.py: take main's standard-alphabet case and keep next's key tests. - tests/test_governance_authorization.py: keep next's unit tests on critical_claims_failure and add main's end-to-end re-signed vector tests (the crit test renamed with an _end_to_end suffix) and the non-string alg test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… directory Implements specs/agent-identity-3-3.md R4 as amended by adcontextprotocol/adcp#8299 (WG-approved 2026-10-19). A governance context, designated-task response signing or rights attestation whose issuer origin O is listed by the trust record derived from O is verified with a key from O's key directory: alg EdDSA, kid the JWK thumbprint, no use/key_ops/adcp_use checks, revocation by O's list. Historical governance verification uses the governance key archive at O. A trust record that cannot be read never selects the 3.2 profile; issuers no trust record lists stay on it. adcp.signing.jws_artifacts adds select_jws_artifact_profile, verify_wba_jws_artifact, WbaJwsArtifactVerifier, HttpGovernanceKeyArchive and compact_jws_from_envelope, reusing TrustRecordEvaluator, DiscoveredSignerKeys and OriginRevocationChecker. verify_governance_authorization takes web_bot_auth= and keeps every other check; verify_historical_governance_context verifies retained tokens through the archive. Outages on the new path are governance_jwks_unavailable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rtifact path A custom key directory or governance key archive that raises anything other than JwsArtifactError now yields jws_key_unavailable (governance_jwks_unavailable) instead of escaping the verifier. An archive entry whose alg is not a string is skipped rather than raising TypeError, and a key the cached archive lacks is refetched at most every 30 seconds, so a key rotated into the archive is not reported unknown for a whole cache period. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Reviewer found no blocking or medium findings. New signing capability (adcp.signing.jws_artifacts, verify_historical_governance_context, web_bot_auth= param, optional profile field) is purely additive — no removals, no required→optional flips on public models — so feat(identity) is the correct semver signal.
Decision table walk-through:
- Row 1 (critical/high): no findings — skip.
- Row 2 (gated_paths): false — skip.
- Row 3 (high-risk deleted): reasons are only
(added)and(modified)— skip. - Row 4 (medium data-loss/schema/infra): no medium findings — skip.
- Row 5 (high_risk modified + medium):
wba.pyis(modified)but there are zero medium findings — does not fire. - Row 6 (sticky escalate): no prior decision — skip.
- Row 7 (no-auto-approve team): no team match — skip.
- Row 8 (≥3 medium): zero medium — skip.
- Row 9: approve.
High-risk flag is a look-closer heuristic; the added jws_artifacts.py is new scaffolding and the modified wba.py drew no medium-or-higher concerns, so the modification is presumed contract-preserving. Reviewer confirmed fail-closed behavior in select_jws_artifact_profile (raises jws_key_unavailable on trust-record outage), kid-to-RFC-7638-thumbprint binding in verify_wba_jws_artifact, and that the #1524 CI fixes correctly un-skip 24 previously-dead signing tests rather than disabling them. Tests: 17211 passed, jws_artifacts.py at 96%.
Implements
specs/agent-identity-3-3.mdR4 as amended by adcontextprotocol/adcp#8299 (WG-approved 2026-10-19): in 3.3, a governance context, designated-task response signing and a rights attestation move to the signing agent's Web Bot Auth key directory.The rule, as implemented
iss(governance context),agent_url(designated-task response) orrights_agent.url(rights attestation), taken from the caller's own context. It is never read from the unverified token.select_jws_artifact_profile): the trust record derived from O (R3a step 1,TrustRecordEvaluator) lists O, with any role → key directory. The record doesn't list O, the operator never published one, or O has no registrable domain → 3.2 profile. A record that can't be read (an outage, an invalid record, a 404/410 after a 200, local capacity) →JwsArtifactError("jws_key_unavailable"), never 3.2.verify_wba_jws_artifact):algexactlyEdDSA,kid= the key's RFC 7638 thumbprint, the key resolved in O's directory (DiscoveredSignerKeys) and checked to be at O. Nouse/key_ops/adcp_usechecks.b64is refused, andcritgoes through the surface's joserfc registry.OriginRevocationChecker). Revoked →jws_key_revoked. Stale or uncheckable →jws_revocation_unavailable, and so is an origin that serves no list, unlessrevocation_absent="accept".HttpGovernanceKeyArchivereads{O}/.well-known/jwks-archive.json, matches keys by thumbprint, refuses an archive that carries private members, treats a 404/410 as no archive, and uses the trust-record fetcher (IP-pinned, no redirects, 2 MiB cap).Surfaces
verify_governance_authorization(..., web_bot_auth=WbaJwsArtifactVerifier(...))selects the profile fromexpected_issuer. On the key-directory pathjwksand the 3.2revocation_checkerare not used. Every other check is unchanged: claims, aud/caller/phase/sub, payload hash, commitment, replay,is_jti_revoked,revocation_resolver, and the caller's brand.json cross-check ofiss(resolve_issuer_jwksstill gatesmake_governance_enforcement_middleware, which passesweb_bot_auththrough**verifier_options).governance_jwks_unavailable, newly added to the error-code literal (the security.mdx taxonomy's retryable code).governance_token_revoked. An origin that serves no list is refused, because a governance context authorizes spend._parse_governance_token.verify_historical_governance_context(token=, expected_issuer=, web_bot_auth=)takes the key from the directory or, once it is rotated out, from the archive (key_source). It checks O's revocation list (an origin with no list has revoked nothing) and skips expiry, audience and replay. An unlisted issuer returns a failure withprofile="3.2".WbaJwsArtifactVerifier.verify(token, artifact=..., signing_agent_url=..., typ=...)supplies the selection and key rules that a surface verifier calls, and raisesJwsArtifactUses32ProfileErrorwhen 3.2 applies.compact_jws_from_envelope(signed_response)builds the compact JWS from a designated-task envelope (decoded payload, JCS signing input). Go takes the same approach (identity.JWSVerifier).verify_wba_governance_issuer(the earlier #7894 draft's check, graded by vendored vectors that carryalgEd25519and ajku) is left as is, with documentation pointing to the R4 path. Re-homing those vectors is P7 work, per #8299.Tests (
tests/test_jws_artifacts_wba.py, 84 tests)profile="3.2".rights_agent.url; the 3.2 hand-off; typ/alg/b64/crit header rules;revocation_absent; malformed tokens, headers and keys.Validation
-k "not packaging and not artifacts", 4 workers): 17211 passed, 2617 skipped.src/adcp/signing/jws_artifacts.py96%.ruff check src/,ruff format --checkon changed files,mypy src/adcp(1538 files): clean.Review
code-reviewer and security-reviewer subagents found no High findings. Fixed:
jws_key_unavailable/governance_jwks_unavailable.algraisedTypeError; it is now skipped.Not changed: key-property failures (non-Ed25519 key, kid ≠ thumbprint) map to
governance_token_invalid, as in TypeScript, rather than the 3.2 path'sgovernance_key_unknown.Cross-SDK differences are documented in
docs/agent-identity-3-3-migration.md. Outages aregovernance_jwks_unavailablehere andgovernance_key_unknownin TypeScript. A trust-record outage is unavailable even when the last record listed O, as in TypeScript; Go keeps the WBA path within the outage-continuation bound.🤖 Generated with Claude Code