Skip to content

fix(signing): match standard Web Bot Auth, and finish the library migration - #1537

Merged
bokelley merged 51 commits into
adcontextprotocol:nextfrom
KonstantinMirin:fix/wba-standard-interop
Oct 19, 2026
Merged

bokelley merged 51 commits into
adcontextprotocol:nextfrom
KonstantinMirin:fix/wba-standard-interop

Conversation

@KonstantinMirin

@KonstantinMirin KonstantinMirin commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

This pull request is stacked on #1523. It adds the adapter that the AdCP Web Bot Auth (WBA) interop harness runs against this SDK, wires the harness into CI, and fixes the places where the SDK and standard WBA disagree. It also removes the hand-written code that #1523 left beside the libraries, as listed in the table on #1523. Cloudflare's npm web-bot-auth 0.2.0 and http-message-sig 0.3.0 are the judge. The protocol draft's editor maintains both packages.

The harness is adcontextprotocol/adcp#8298, for adcontextprotocol/adcp#8297.

#1539 carries the AdCP 3.2 fixes to main: the crit coherence check, the non-string alg check, the #1524 test fixes, and fastapi and svix in the CI install. next picks them up through the regular sync. This pull request no longer carries the #1524 test fixes or the 3.2 half of the alg check. Two of the fixes stay here as well, because the Web Bot Auth path depends on them: the crit check lives in critical_claims_failure, which verify_wba_governance_issuer shares with the 3.2 verifier, and strict base64url decoding comes from joserfc here, through import_jwk. If the main sync into next conflicts in crypto.b64url_decode or in the crit check of verify_governance_authorization, keep next's side, which already carries each fix.

Changes

The commits make the following changes:

  1. Adapter and CI job (test(signing), ci(signing)). tests/wba_interop/adapter.py reads one JSON operation on standard input (sign_request, sign_directory, verify_request, or verify_directory) and writes one JSON result. It calls only the public adcp.signing API. It reports the SDK's refusals, WbaVerificationError and the ValueError of a refused signature, as ok: false. Any other exception crashes it, so the harness never grades an SDK defect as a correct refusal. The wba-interop job in ci.yml checks out tests/wba-interop/ and the wba-profile vectors at ADCP_REF (306cdfa6), runs npm ci on Node 24, and runs the harness against uv run python tests/wba_interop/adapter.py with JUnit output.
  2. Web Bot Auth rules (fix(signing), scenarios R10, D08, and D11). The changes are the following:
    • The WBA path signs and verifies with ed25519 only. RSA-PSS leaves the signer, the verifier, the directory, the governance issuer check, and the RFC 9421 algorithm table. The AdCP RFC 9421 profile keeps Ed25519 and ES256.
    • The verifier refuses a signature without alg. It used to default alg to the key's algorithm.
    • A directory key the profile can't use, such as a P-256 or RSA key, is ignored, and so is its signature. A usable key that doesn't sign refuses the directory. The SDK used to refuse a directory in which a P-256 key signed, and to drop a usable key that didn't sign.
    • A directory served with a media type other than application/http-message-signatures-directory+json is refused, and the directory fetch asks for that media type only. The SDK used to accept, and ask for, application/json and application/jwk-set+json.
    • An origin's revocation list is verified as EdDSA only: OriginRevocationChecker passes allowed_algs={"EdDSA"}, a new parameter of AsyncCachingRevocationChecker.
  3. Strict base64url and one JWK import (fix(signing)). b64url_decode used the standard library's lenient decoder, so verifier step 8 accepted a JWK whose x had !! inserted. b64url_encode, b64url_decode, and public JWK import come from joserfc through one helper, crypto.import_jwk, which the JWS and governance paths share. The SDK keeps the 32-byte P-256 member check, which joserfc leaves open. import_jwk drops the private members of a public key before joserfc sees them: joserfc derives the public key from a d it is given, and a kid or a publisher pin names the key by a thumbprint over x. private_key_from_jwk builds the key from d, so a d-only JWK loads, and it refuses a JWK whose x (or y) isn't the public key of d.
  4. Library residue (refactor(signing)). public_directory_key exports with OKPKey.as_dict and thumbprint. jwk_thumbprint passes joserfc the members its key registry marks as required, instead of an RFC 7638 member table. The async signer and the verifier build Signature-Input with http-sf, and sign_key_directory appends each key's signature with HTTPMessageSigner.sign(append_if_signature_exists=True) instead of ", ".join.
  5. Member types (test(signing), scenarios R18, R18c, and R18d). Tests pin the existing behavior: a Signature-Agent member with type=directory verifies, as a Token and as a String, and a member with any other type, such as jwks_uri or cimd, is ignored (403).
  6. Malformed members (fix(signing), scenario R20b). Every Signature-Input member must be an inner list and every Signature member a Byte Sequence with no parameters, under every label (400). A member with another tag is still not read otherwise. The check runs before a member is selected, so a malformed web-bot-auth member is 400 too, and directory verification applies it as well.
  7. Canonical origin (fix(signing), scenarios R17, R17c, R17d, and R17e). The Signature-Agent value must be exactly the canonical https origin string: a lowercase host, no default port, no trailing /, 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. Any other spelling is 403 before key discovery. The signer already sent the canonical origin, and a test pins it.
  8. Private key material (test(signing), scenarios D14, D14b, and D14c). Tests pin the existing behavior: a directory in which any key carries a private member is refused, whatever the case of the member name (d, D), before unusable keys are skipped.
  9. Governance vectors out of the profile (test(signing)). The governance vectors leave the WBA profile vectors, and verify_wba_governance_issuer stays as an AdCP-layer check after WBA verification. Its three vectors move to vectors/wba-governance, with their own provenance record, and its tests move to test_wba_governance.py.
  10. Vectors and harness pin (test(signing), ci(signing)). The wba-profile vectors are vendored from adcontextprotocol/adcp at e5929470, the #7894 head. The request and directory vectors keep their signatures and change only their comments, and scenarios.json comes with them, D14c included. ADCP_REF is 306cdfa6, the #8298 head, where the harness grades an adapter crash as a failure.
  11. http-message-signatures 2.1 (refactor(signing)!). 2.1.0 resolves the RFC 9421 ;key and ;req component parameters (Resolve the RFC 9421 key and req component parameters pyauth/http-message-signatures#30) and adds HTTPMessageSigner.prepare and apply (chore(main): release 1.3.1 #31). It also replaced the resolver's url attribute, which the SDK's resolver subclasses read, so they failed every signature base under 2.1. The SDK's resolver subclasses are gone: every profile uses the library's HTTPSignatureComponentResolver. 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, and Web Bot Auth hands it the target URI as received. The async signer uses prepare and apply instead of the protected base hook. The floor is 2.1.0.
  12. Governance residue (refactor(governance), refactor(signing)). Both governance verifiers split tokens with jws.parse_compact_jws and parse segments with parse_strict_json. Both check crit with one helper, critical_claims_failure, built on JWSRegistry.check_header, and build their registry with one factory. Each commit body says which tests gate it and what changes.
  13. Governance fixes (fix(governance), fix(signing)). Each critical marker, its crit entry, and its claim must appear together, as security.mdx checklist step 4 requires. The WBA issuer check refuses a token alg that isn't a string as governance_token_invalid, and refuses a PS512 token before key discovery.

docs/agent-identity-3-3-migration.md follows each change.

API changes

Outside the WBA preview

369ddf37e carries a BREAKING CHANGE: footer for these changes, which affect functions that also exist on main 9.x. docs/request-signing-migration.md section 7 lists them for adopters.

  • adcp.signing.b64url_decode accepts only RFC 7515 base64url and raises ValueError for padding, characters outside the URL-safe alphabet, and non-zero trailing bits.
  • public_key_from_jwk imports with joserfc, so its ValueError text changes. It reads only the public members, as it did before this pull request.
  • private_key_from_jwk raises ValueError when a JWK's x (or y) isn't the public key of its d. A d-only JWK loads, as before.
  • adcp.signing.publisher_pins.jwk_thumbprint returns the base64url thumbprint (str) instead of the digest bytes.

8ccabed42 carries a BREAKING CHANGE: footer for the http-message-signatures 2.1 adoption. http-message-signatures 2.1 is the reference for these changes, and neither the #7894 profile nor RFC 9421 requires otherwise:

  • 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 or a malformed percent escape, and the verifiers refuse such a request. The error says to percent-encode the URL; httpx encodes those characters when they come through params=, but not when they are written into the URL.
  • A covered derived component beyond @method, @target-uri, and @authority, such as @path, resolves and verifies. The SDK used to refuse it.
  • A covered header's obsolete line folding is unfolded, as RFC 9421 section 2.1 requires, instead of refused.
  • Under Web Bot Auth, @target-uri for a URL with no path ends in /, a fragment or userinfo is dropped, @authority strips a zero-padded default port, and a target URI that isn't a valid URI is 403, the profile's status for a failed verification, instead of 400.

In the WBA preview

The WBA profile is experimental and this stack targets the 3.3 draft line, so these ship without a breaking-change marker:

  • adcp.signing.wba.ALG_RSA_PSS_SHA512 is removed, and WBA_ALGORITHMS is {"ed25519"}.
  • sign_wba_request, public_directory_key, and sign_key_directory raise ValueError for an RSA key.
  • verify_wba_request refuses a Signature-Agent value that isn't the canonical origin string.
  • verify_directory_response and DiscoveredSignerKeys refuse a directory served as application/json or application/jwk-set+json, and a directory in which a usable key doesn't sign.
  • AsyncCachingRevocationChecker and its from_issuer_origin take allowed_algs, which defaults to ALLOWED_JWS_ALGS.

Behavior changes in the 3.2 governance path

The governance commits keep every outcome and error code, with the following exceptions. Each one is governance_token_invalid:

  • A crit entry whose marker is absent, or isn't a boolean, is refused even when its claim is absent too. RFC 7515 section 4.1.11 forbids a producer to list a name that doesn't occur in the header, and lets a recipient refuse it. security.mdx checklist step 4 requires the refusal.
  • A marker for a claim the payload doesn't carry is refused: a crit entry with a false marker, or a marker without a crit entry. security.mdx checklist step 4 (AdCP 3.2.1 to 3.2.5) requires "each boolean protected-header marker, its crit entry, and its payload claim to appear together".
  • A token segment with an unpaired surrogate escape is refused, as the revocation-list and WBA paths refuse it.
  • Nesting deep enough to raise RecursionError is refused instead of raising.
  • Some failure messages change, because the shared splitter refuses a malformed header before the JSON step.

The WBA issuer check shares these rules. On the 3.2 path, a header alg that isn't a string is fixed in #1539.

Spec basis

  • Ed25519 only: following the current #7894 page (5c3876073f). The working group hasn't recorded a decision on that page, so if it reverses this, the RSA removal is reverted too. The profile's alg is ed25519, and the revocation-list alg is EdDSA.
  • alg required: wba-profile.mdx, Profile rules ("created, expires, keyid, alg, and a nonce"; "named in alg, which must match the key").
  • Unusable keys ignored, every usable key signs: the #7894 answer to gap 3, RFC 7517 section 5, and WBA Appendix B.1.
  • Private key material: wba-profile.mdx at e5929470 refuses a directory in which any key carries a private member, checked before unusable keys are skipped (scenarios D14, D14b, and D14c).
  • Media type: draft-ietf-webbotauth-httpsig-protocol-00 section 5.5.1.
  • Signature-Agent: draft-ietf-webbotauth-httpsig-protocol-00 section 5.2.1 (the bare origin, and type=directory), as wba-profile.mdx at e5929470 narrows it: the value is exactly the canonical https origin string, compared without normalizing, and type=directory is accepted as a Token or a String. The covered member is the one keyed to the signature's label.
  • Signature field members: RFC 9421 sections 4.1 and 4.2.
  • Nonce: the #7894 answer to gap 1, option B. The SDK already accepted exactly the two forms that Cloudflare's validateNonce accepts, and it still does.
  • Governance: the governance checks leave the WBA profile. verify_wba_governance_issuer stays, as the AdCP-layer check that the R4 erratum relies on. The crit rules follow security.mdx checklist step 4 at AdCP 3.2.1 to 3.2.5.
  • Content digest: AdCP 3.2 request-signing.mdx ("every accepted signature on a request with a body must cover Content-Digest").

Interop results

The harness at adcontextprotocol/adcp 306cdfa6 (#8298, the CI pin), with http-message-signatures 2.1.0 installed, passes 64 of 64 checks on this head: tier wba 23 of 23, tier adcp 26 of 26, D14c included, with no adapter crash. At the earlier pin ef966de1, #1523's head (e81d8009) passes 55 of 63.

The following table lists every check whose result changed. "Accepted" means the SDK accepted a request or directory that the scenario expects it to refuse:

Check Tier Expected e81d8009 This pull request
R10 no alg parameter wba 403 accepted 403
R17 Signature-Agent with a path adcp 403 accepted 403
R17c Signature-Agent origin with a trailing / adcp 403 accepted 403
R17d Signature-Agent origin with an uppercase host adcp 403 accepted 403
R17e Signature-Agent origin with an explicit :443 adcp 403 accepted 403
R20b second member whose Signature value is a token wba 400 accepted 400
D08 directory served as application/json adcp refuse accepted refuse
D11 second Ed25519 key that doesn't sign adcp refuse accepted refuse

The harness prototype, with the same judge, also covers RSA and one more directory case:

Case Expected e81d8009 This pull request
Cloudflare-signed RSA request, POST and GET refuse (Ed25519 only) accepted 403
Cloudflare-signed RSA directory refuse (Ed25519 only) accepted refuse
SDK-signed RSA request and directory not signed (Ed25519 only) signed signing refused
Directory in which an RSA key signs beside the Ed25519 key accept accepted accepted

Testing

{{TESTING}}

Not in this pull request

  • Rows of the refactor(signing): RFC 9421 on http-message-signatures; align Web Bot Auth with adcp#7894 #1523 table that stay. The following hand-written code stays, with the reasons:
    • jws.parse_general_json_jws converts the general JSON form to the compact form (RFC 7515 section 7.2.1). joserfc.jws.extract_general_json isn't in that module's __all__, and it doesn't decode the signature.
    • The detached-JWS verifier joins the three segments, because its public API takes them apart.
    • The iat, nbf, and exp checks carry the SDK's rules: finite numbers only, integers only on the WBA path, exp after iat, and a valid verifier clock. JWTClaimsRegistry needs the same checks plus a mapping of its errors to the AdCP codes.
  • An upstream gap. build_signature_base, and the Web Bot Auth diagnostic base, still build a base through the library's protected _build_signature_base. 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 hook.

Before merge

bokelley and others added 10 commits October 15, 2026 18:41
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 adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…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 adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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 adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lignment

- 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 adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compare the admitted origins as an ordered list rather than by substring,
which CodeQL reads as URL sanitization.

Refs adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment the expected decode failures in valid_wba_nonce and give the
revocation status protocol an explicit body.

Refs adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 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 adcontextprotocol#1511, adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d 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 adcontextprotocol#1511, adcontextprotocol#1500

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 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 adcontextprotocol#1511

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 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 adcontextprotocol#1511

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean library-migration refactor of the RFC 9421 / WBA signing stack (33 files, +1744/-713).

No blocking findings. The reviewer verified the load-bearing paths:

  • crypto.import_jwk consolidation keeps the 32-byte P-256 member check and strict b64url_decode rejects padding/non-canonical bits.
  • _check_signature_members (wba.py ~252) enforces RFC 9421 §4.1/4.2 shape before selection, making the dropped is_inner_list guard safe.
  • Canonical-origin check (wba.py ~1096) rejects before key discovery; directory rules (one media type, ignore-unusable, every-usable-signs) are consistent and tested.
  • Governance critical_claims_failure builds on joserfc check_header plus SDK checks; StrictJsonError subclasses ValueError, so the governance except (TypeError, ValueError) still catches malformed JSON / RecursionError.

Semver: Ed25519-only removal of ALG_RSA_PSS_SHA512 ships under fix without a breaking marker — defensible here because the WBA profile is experimental and the surface is unreleased (stacked on #1523), so not flagged as a public-surface break.

Decision table: no critical/high/medium findings; gated_paths false; high_risk is true but all signing-path entries are (modified) with no medium-or-higher finding attached, so rows 3–5 do not fire. No team gate, no prior decision. Falls through to row 9 → approve.

Non-blocking follow-up (already on author's "Before merge" list): ci.yml ADCP_REF pins adcp#8298's PR-head commit; re-pin to the merge commit so wba-interop stays reproducible.

KonstantinMirin added a commit to KonstantinMirin/adcp-client-python that referenced this pull request Oct 18, 2026
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 adcontextprotocol#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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — signing/governance hardening plus test coverage, no blocking findings.

Checked:

  • critical_claims_failure loop rewrite (governance.py:1457) enforces marker/crit-entry/claim coherence with marker is True; verified across all four cases and new parametrized tests.
  • import_jwk(private=False) drops private JWK members so a stray d never selects the verification key (crypto.py); well covered.
  • type(alg) is not str guards both governance verifiers against non-string alg.
  • WBA governance alg allow-list narrowed to Ed25519 (drops PS512), matching the profile; RSA_PSS_SHA512 removed from the private _rfc9421.ALGORITHMS (no public break).
  • Pin/doc/vector bumps to 2546cf5f are internally consistent.

Gate posture:

  • high_risk is true but all reasons are (modified) files under src/adcp/signing/** with zero medium-or-higher findings → row 5 does not fire; no deletions → row 3 does not fire.
  • gated_paths is false; review_decision is APPROVED.
  • No no-auto-approve team match.
  • Prior decision was approve (not sticky-escalate).

No critical/high/medium findings → rows 1–8 do not fire. Falls through to row 9: approve.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean test-only delta since prior approve.

The delta since the prior clean approve (head 853c841) is test-only: a parametrized test (P-256/RSA/Ed25519) asserting that a key directory publishing private JWK material (d) is refused with "private key material". The reviewer verified it exercises real production behavior — the fail-closed scan at wba.py:632-633 rejects private JWK members before unusable keys are skipped — and the test helper correctly handles all three key types.

No critical/high/medium findings. high_risk is true (signing paths modified), but per the decision rules the flag alone is not escalation-worthy: the reviewer surfaced no medium-or-higher concerns on the modified files, so the modifications are presumed safe. gated_paths is false and review_decision is APPROVED. No no-auto-approve team match. Rows 1–8 do not fire → row 9 approve.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — vendored WBA profile test-vector refresh with no actionable findings.

This PR re-pins experimental adcp#7894 conformance vectors (2546cf5f → f311e1b5), with the commit SHA moving consistently across UPSTREAM.json, UPSTREAM-README.md, README.md, docs/agent-identity-3-3-migration.md, and the test_wba_profile.py provenance assertion. It bumps the interop harness ADCP_REF and adds conformance scenarios (R18 base refinement, new R19b/R21b, directory cases D09–D14b, and the D03 wba→adcp tier reclassification). All four pin references agree on f311e1b5; the recorded scenarios.json SHA-256 is self-enforced by CI; the load-bearing D13/D14 ordering reads consistently.

No SDK source, public surface, credential, forward-compat, import-layering, or generated-code changes. No semver-signal concerns.

Decision rationale: No critical/high/medium findings (table rows 1, 4, 5, 8 don't fire). gated_paths is false (row 2 n/a; review_decision is also APPROVED). high_risk is true but all reasons are (modified) with zero medium-or-higher findings — rows 3 and 5 don't fire. Prior decision was approve, so sticky row 6 n/a. No no-auto-approve team match (row 7 n/a). Falls through to row 9 → approve.

@bokelley bokelley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I verified

I checked out the head at 3f04bd2b in a clean worktree on Python 3.12, with .[dev], fastapi, and svix installed.

  • pytest. The signing conformance suite plus the identity, governance, and WBA test files (test_identity_3_3_alignment, test_governance_authorization, test_wba_*, test_signed_request_verification, test_webhook_signing_capabilities, and the other identity files): 2,401 passed, 2 skipped. The full tests/conformance suite: 4,116 passed, with 2,507 skipped (Postgres). The six #1524 files: 92 passed, 0 failed.
  • ruff and mypy. ruff check src/ is clean. mypy src/adcp/ reports no issues in 1,536 files.
  • Harness at the PR's pin (#8298 ef966de1). 63 of 63 pass (wba 23/23, adcp 25/25). The same pin against #1523's head e81d8009 gives 55 of 63. The 8 failures are exactly the ones in your table: R10, R17, R17c, R17d, R17e, R20b, D08, and D11.
  • Harness at the current #8298 head (ac2b3ffa). 64 of 64 pass, including the new D14c (upper-case D). I logged every adapter refusal. All 41 are deliberate WbaVerificationError reasons, and none is a crash.
  • The order-dependent failure you describe. test_stateful_mcp_session_reads_each_requests_own_wba_signer fails after tests/test_wba_task_ownership.py. The cause is the _HANDLER_TOOLS registry, which is keyed by class name, so the two Handler classes collide. It's a problem in the tests, not in this PR.

Findings, by severity

Medium

  1. This PR bundles AdCP 3.2 fixes into a draft-spec stack. RELEASING.md says "Fixes land on main first", and this stack only reaches next. Three groups of changes in this PR fix the ratified 3.2 line:

    • The governance crit coherence check and the non-string alg check (governance.py, critical_claims_failure). I checked them against security.mdx checklist step 4 on adcp main ("require each boolean protected-header marker, its crit entry, and its payload claim to appear together"), and they are correct.
    • The #1524 test fixes.
    • Arguably the strict b64url_decode and the private-member drop in import_jwk, because public_key_from_jwk is on the RFC 9421 3.2 verifier path.

    On main at 5df00364 with fastapi and svix installed, the six #1524 files still fail: 26 failed and 48 passed out of 74 collected. Main also still accepts the crit/marker combinations this PR refuses.

    Suggestion: split these into a PR against main, and let next pick them up through the regular sync. Keep this PR to the WBA changes. This PR doesn't close #1524 in any case, because CI still installs neither fastapi nor svix (ci.yml:134, .[dev]).

  2. No CI has run on this PR or on #1523. ci.yml runs pull_request only for main, next, and the conductor/* branches. This PR's base, claude/issue-1511-rfc9421-library, isn't one of them. As a result, test, schema-check, and the new wba-interop job have never run on GitHub. The only checks so far are GitGuardian, IPR, check, and code_review, so every result in the description is local. They reproduce for me, but the jobs need to run on GitHub before merge.

    After retargeting to next, confirm the run. Also check whether Web Bot Auth interop with the Cloudflare reference is a required check: I couldn't read branch protection, and the job isn't in any aggregate gate.

  3. The stack inherits a conflict with next. git merge-tree origin/next conflicts in src/adcp/signing/verifier.py (imports) for both #1523 and #1537. next now imports target_uri_precheck from _header_precheck, which came in with #1479. The resolution in #1523 has to keep it and its call site, or the 3.2.4 target-URI precheck is silently lost. #1514 merges cleanly onto next, but it is 8 commits behind.

Low

  1. The directory Accept header still offers the media types the verifier now refuses. wba.py:777-779 sends application/jwk-set+json;q=0.9, application/json;q=0.8, while _verify_directory accepts only DIRECTORY_MEDIA_TYPE. A server that negotiates content could pick application/json and then be refused. Send only DIRECTORY_MEDIA_TYPE.

  2. private_key_from_jwk now requires x but never checks it (crypto.py:139-148). I passed d from one key and x from another. The function accepted the pair, and the derived public key doesn't match x. A d-only JWK used to work in WebhookSender.from_jwk and now raises. Either check that x matches the public key derived from d (a misconfiguration then fails at load time instead of at the receiver), or don't require x. As written, it's an API break with no benefit.

  3. The WBA revocation-list path still allows ES256. OriginRevocationChecker._discover (wba.py:1601) builds AsyncCachingRevocationChecker.from_issuer_origin without an algorithm list. averify_jws_document (revocation_fetcher.py:1124) therefore falls back to ALLOWED_JWS_ALGS = {EdDSA, ES256}. It can't be exploited, because directory keys are Ed25519 only and joserfc refuses ES256 with an OKP key. But your #7894 answer (item 5) says this path narrows to EdDSA for Python, and this PR doesn't do it. Either pass {"EdDSA"} or correct the answer.

  4. The PR is inconsistent about the Ed25519 algorithm name, and the profile doesn't define a usable key.

    • A directory key that carries "alg": "Ed25519" (the RFC 9864 fully-specified name) is ignored (wba.py:340). The governance path now accepts the JWS alg Ed25519 (jws.py:113, wba.py:1223).
    • An Ed25519 key with use: "enc", or with key_ops lacking verify, is ignored instead of being required to sign. The page says "Every Ed25519 key in the directory signs the response."

    Neither case can be exploited, because an ignored key never verifies anything. Still, the profile should say what "a key the profile uses" means, so that all three SDKs ignore the same keys.

  5. The pins are behind the heads. ADCP_REF (ci.yml:385) is ef966de1 and the vendored vectors are f311e1b5. The #8298 head is now ac2b3ffa and the #7894 head is e5929470, which add D14c. The SDK already passes D14c, because wba.py:589 lower-cases member names. No in-repo test covers an upper-case D, so add one when you re-vendor.

  6. The adapter's catch-all except turns a crash into a correct refusal. adapter.py:148 catches every Exception. The harness grades a directory refusal as a pass when ok is false and the scenario has no expected status (run.mjs:76). It also maps an adapter that crashes outright to ok: false (run.mjs:51). So a TypeError in the SDK grades as a correct refusal on D08 to D14c.

    • In the adapter: convert only WbaVerificationError and ValueError, and let anything else crash.
    • In #8298: don't count rule: "adapter-crash" as a refusal. This affects every SDK's adapter.
  7. The multi-key directory signing path is untested. sign_key_directory now uses append=True (wba.py:746). Every in-repo call passes one key, and the harness also signs directories with one key (run.mjs:117). I checked a 3-key round trip by hand and it works. Add a test with at least two keys.

  8. Some changed APIs need a breaking-change marker. You asked whether these do. b64url_decode (strict), private_key_from_jwk (x required), public_key_from_jwk (error text), and publisher_pins.jwk_thumbprint (returns str, not bytes) are exports that also exist on main 9.x. On next they ship in 10.0.0a. A BREAKING CHANGE: footer in the squash commit puts them in CHANGELOG.next.md. The WBA changes are fine without one, since the WBA profile is experimental.

  9. The basis for Ed25519-only. The description cites "the profile decision of 2026-10-17 on #7894". The change is 5c3876073f on #7894, which is still open, and the page says nothing on it is normative until the working group records a decision. I agree with the change; the page now says ed25519 only. Please word it as following the current page, so that if the working group reverses it, the RSA removal is reverted too.

Nit: in the interop table, "pass" in the e81d8009 column means the SDK accepted the request. "accepted" would read more clearly.

Where this is right

  • R10. A signature without alg is now refused. The page's parameter row requires alg, so defaulting it to the key's algorithm was a defect in #1523.

  • R17 family. Signature-Agent is compared exactly against the canonical origin. type(agent[0]) is not str still refuses a Token.

  • Directories.

    • Only the one media type is accepted.
    • Unusable keys and their signatures are ignored.
    • Every usable key must sign.
    • The private-member scan, which ignores case, runs before the skip.

    This matches the page and Appendix B.1. Skipping an ignored key's member before the tag and window checks is the right reading.

  • R20b. Every member must be well-formed before one is selected. This matches step 1 of the page.

  • One import_jwk. Private members are dropped before joserfc sees them. Before this PR, a stray d beside x on the JWS path selected the key from d. I confirmed that public_key_from_jwk now ignores d.

  • Strict base64url. JWK members are now strict. I confirmed that padded, standard-alphabet, and non-zero-trailing-bit spellings of x are refused, and that each has no thumbprint.

  • Removed residue. Removing _check_critical_markers and _governance_registry from wba.py in favour of one shared helper and one registry removes real duplication that #1523 left.

  • #1524 test fixes. The changes are correct: the retry is re-signed (the receiver refuses a replayed nonce before dedupe), the digest-off case signs with the 3.1 profile, and operation_id is passed to send_mcp.

  • Unchanged from #1514/#1523. The diff doesn't touch these:

    • the nonce rule (same accepted set, matching the page);
    • one signature (R21 and R21b pass);
    • signed directories required;
    • 429 with a fresh Accept-Signature;
    • the unsigned-request rule;
    • WbaAuthorizer;
    • _WbaComponentResolver;
    • the duplicate-header body-swap fix.

Base branch and stacking

The base is correct for the WBA code. This PR targets #1523's branch, so it lands on next, not main, and it doesn't conflict with the branch model. It is a direct continuation of #1523: every behavioural change here corrects #1523 against the #7894 page as it stands now (RSA, alg, origin, media type, directory keys). Nothing here duplicates #1514.

Recommended order:

  1. Merge next into #1514, then merge #1514.
  2. Retarget #1523 to next, resolve the verifier.py conflict keeping target_uri_precheck, and merge it. Alternatively, fold this PR into #1523 first, since #1523 doesn't match #7894 without it.
  3. Retarget this PR to next and bump ADCP_REF and the vectors to the current heads. Let CI run, including wba-interop, and merge.
  4. Separately, open a PR against main with the 3.2 governance fixes and the #1524 test fixes, and add fastapi and svix to the CI install (finding 1).

🤖 Generated with Claude Code

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.
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).
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).
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 ", ".
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.
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.
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.
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.
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.
…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.
…ed 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.
…efusal

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.
adcontextprotocol#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.
…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.
…K'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
(adcontextprotocol#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.
…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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — completes the http-message-signatures 2.1 migration with a clean reviewer pass.

No critical/high/medium findings. Walking the decision table top-down:

  • Rows 1 (no critical/high), 4, 8 (no medium findings) — no match.
  • Row 2 (gated_paths) — gated_paths is false; also review_decision is APPROVED. No match.
  • Row 3 (deleted high-risk path) — all high_risk_reasons are (modified), none deleted. No match.
  • Row 5 (high_risk modified + medium finding) — high_risk is true for signing/** modifications, but there are zero medium findings. No match.
  • Row 6 (sticky escalate) — prior decision was approve, not escalate. No match.
  • Row 7 (no-auto-approve team) — no team match. No match.
  • Falls through to Row 9 → approve.

What was checked: the diff drops custom ComponentResolver subclasses for the library resolver, routes AdCP canonicalization through canonical.signing_message, moves the async signer to prepare/apply, narrows WBA to ed25519, and tightens JWK/base64url decoding onto joserfc. _rfc9421.verify() fails closed on alg/key mismatch; wba.py refuses private-material directories case-insensitively; one-signature-per-usable-key is enforced. Semver signal is satisfied — refactor(signing)! with BREAKING CHANGE footers plus migration prose in request-signing-migration.md §7; the unmarked WBA-preview removals are correct for the experimental 3.3 surface.

Non-blocking note: wba-interop CI pins ADCP_REF to the unmerged #8298 head, but the job comment already flags re-pinning on merge — low, not surfaced as a blocker.

…g-storage

Ports the driver change from adcontextprotocol#1521 (bde770b) on main. The JS SDK moved
createFilesystemReportingRowObjectProviderV1 out of the ledger package, so the
row-storage interop lane fails on any branch cut before adcontextprotocol#1521. The same change
is already on main and next, so the sync merges cleanly.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean delta, no blocking findings.

The incremental change since the prior approve is a single test-only file: the JS half of the cross-SDK row-storage interop driver (tests/interop/reporting_row_storage_js_driver.cjs). It adds a fallback resolving ReportingRowStorageV1 and createFilesystemReportingRowObjectProviderV1 from @adcp/reporting-storage when SDK 16 stops exporting them from @adcp/sdk's reporting/ledger entry. The gate verifies both symbols are functions before use, rowStorage.* references track the resolved binding, and the fallback fails closed via the existing requireFromSdk helper. No production surface, no correctness issue.

Decision-table walk:

  • Rows 1 (critical/high): no findings — skip.
  • Row 2 (gated_paths): gated_paths is false — skip.
  • Row 3 (high-risk deletion): all high-risk reasons are (modified), none deleted — skip.
  • Row 4 (medium data-loss/schema/infra): no findings — skip.
  • Row 5 (high-risk modified + medium): high_risk is true with modified signing files, but there are zero medium findings — does not fire.
  • Row 6 (sticky escalate): prior decision was approve, not escalate — skip.
  • Row 7 (no-auto-approve team): no team match — skip.
  • Row 8 (3+ medium): zero medium — skip.
  • Row 9: approve.

high_risk is true only via (modified) signing-path files with no reviewer-flagged concern; per the high-risk guidance a modification with no medium-or-higher finding is presumed safe. review_decision is APPROVED. Net result is an approve.

@KonstantinMirin

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Each finding, with its commit or answer:

  1. Split. fix(governance): require crit markers with their claims, and run the signing tests CI skips #1539 carries the 3.2 fixes to main: the crit coherence check, the non-string alg check, strict b64url_decode (with the standard library), the Six signing test files never run in CI, and 26 of their tests fail on main #1524 test fixes, and fastapi and svix in the dev extra, so it closes Six signing test files never run in CI, and 26 of their tests fail on main #1524. next picks them up through the regular sync. I rebuilt this branch's history without the Six signing test files never run in CI, and 26 of their tests fail on main #1524 test commits and the 3.2 half of the alg fix. The crit check and strict decoding stay here too, because the Web Bot Auth path shares them: verify_wba_governance_issuer uses critical_claims_failure, and the JWK import here decodes through joserfc. If the sync conflicts in either place, keep next's side.
  2. CI. We follow your order: fix(identity): align AdCP 3.3 agent identity with the final spec and the Go and TS SDKs #1514, then refactor(signing): RFC 9421 on http-message-signatures; align Web Bot Auth with adcp#7894 #1523 retargets to next, then this pull request retargets to next and CI runs, wba-interop included. I can't read branch protection either, so whether the wba-interop check is required stays open.
  3. Conflict with next. This pull request doesn't touch it. git merge-tree shows the same single conflict in verifier.py at refactor(signing): RFC 9421 on http-message-signatures; align Web Bot Auth with adcp#7894 #1523's head and at this head: the import lines. The resolution keeps both imports, from adcp.signing import _rfc9421 and strict_header_precheck, target_uri_precheck. The target_uri_precheck call site merges without a conflict.
  4. Directory Accept. The request offers DIRECTORY_MEDIA_TYPE only: 0b7518853.
  5. private_key_from_jwk. It reads d alone, so a d-only JWK loads again. When x (or y) is present and isn't the public key of d, it raises ValueError at load time: c82d27a79.
  6. Revocation lists. OriginRevocationChecker passes allowed_algs={"EdDSA"}, a new parameter of AsyncCachingRevocationChecker, so a list with another alg is refused before its key is resolved: 4635f7589.
  7. Usable keys. No change. The directory reads a key only when the library imports it as an Ed25519 key it can verify with, and it ignores every other entry. As you found, an ignored key never verifies anything, so no case is exploitable. jws.py:113 is unreachable on the 3.2 path: both 3.2 callers refuse any alg but EdDSA and ES256 before they build a registry. Only the Web Bot Auth governance check selects it.
  8. Pins. ADCP_REF is 306cdfa6, the #8298 head, and the vectors come from e5929470, the #7894 head; only scenarios.json changed, adding D14c: 4de1237d9. 67a844f26 adds the upper-case D test, with P and Q. The harness passes 64 of 64, with no adapter crash.
  9. Adapter. It reports only the SDK's refusals, WbaVerificationError and the ValueError of a refused signature, and anything else crashes it: ab4f883f2.
  10. Multi-key directories. No test added. sign_key_directory appends each signature through the library's append_if_signature_exists, and your three-key round trip confirms it.
  11. Breaking changes. 369ddf37e carries the BREAKING CHANGE: footer for b64url_decode, the public_key_from_jwk error text, the private_key_from_jwk refusal of a mismatched x, and publisher_pins.jwk_thumbprint returning str. docs/request-signing-migration.md section 7 lists them for adopters, and so does the description.
  12. Ed25519-only basis. The description and the migration guide say that this follows the current #7894 page (5c3876073f): 40155e7f7.

Nit: the interop table says "accepted".

http-message-signatures 2.1. 2.1.0 replaced the resolver's url attribute, which the SDK's resolver subclasses read, so every signature base failed once it installed. It also resolves ;key and ;req (pyauth#30) and adds prepare/apply (#31). 8ccabed42 adopts it: the SDK's resolver subclasses are gone, the AdCP profile hands the library its canonical target URI, and the async signer uses prepare/apply. The one protected call left, _build_signature_base, builds a base for caller-ordered parameters, which the library has no public call for. #1523 needs the same adoption, or it breaks as soon as 2.1 installs. The observable changes follow the library and are listed in the description. One affects callers: a raw {, }, or | in a URL's query is refused, so percent-encode it; httpx encodes those characters through params=, but not when they are written into the URL.

The other profile edge cases need no change:

  • A malformed Signature or Signature-Input member is 400 before any member is selected, with or without a web-bot-auth member.
  • A Signature-Agent port that isn't the canonical spelling (:0443, :99999) is 403 before key discovery.
  • A second directory signature by the same key refuses the directory.
  • The signers refuse a non-https agent origin when they're called, and there's no http opt-in.

Testing. Local CI on 4de1237d9 ran on macOS, on Python 3.12 and 3.10, with fastapi, svix, and http-message-signatures 2.1.0 installed:

Because upstream CI doesn't trigger on this pull request's base, the same ci.yml ran in my fork, on a CI-only pull request whose base is #1523's head (KonstantinMirin/adcp-client-python#2). It passes 35 checks, wba-interop and the Postgres lanes included. Only the checks that need this organization fail there: IPR, the commit-policy validator, and the AI review.

bokelley and others added 3 commits October 19, 2026 14:39
…o claude/issue-1511-rfc9421-library

Brings in next through adcontextprotocol#1514's branch. The one conflict was the import
block of src/adcp/signing/verifier.py: this branch imports `_rfc9421`,
and next (adcontextprotocol#1479, AdCP 3.2.4) imports `target_uri_precheck`. The
resolution keeps both, and keeps the precheck's call site, so a
malformed wire authority is still refused as request_target_uri_malformed
at step 6 before key lookup and replay claims. A new regression test in
test_target_uri_malformed_boundary.py fails if the precheck is dropped.

http-message-signatures 2.1.0, released 2026-10-19, rewrote the
component resolver this layer subclasses (no `url` attribute). Cap the
dependency below 2.1 until the SDK moves to 2.1, which adcontextprotocol#1537 carries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…511-rfc9421-library

next's tree at 2b08ced equals adcontextprotocol#1514's head 6637374, which this branch
already merged, so the merge records the squash commit without changing
the tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bokelley
bokelley deleted the branch adcontextprotocol:claude/issue-1511-rfc9421-library October 19, 2026 17:54
@bokelley bokelley closed this Oct 19, 2026
@bokelley bokelley reopened this Oct 19, 2026
@bokelley
bokelley changed the base branch from claude/issue-1511-rfc9421-library to next October 19, 2026 17:55

bokelley commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

@KonstantinMirin, #1514 (2b08cedc) and #1523 (0701c8be) are merged into next. When #1523 merged, GitHub deleted its branch, which was this PR's base, and that closed this PR automatically. I restored the branch, reopened the PR, retargeted it to next, and deleted the branch again. Nothing on your branch changed.

This PR now conflicts with next, so CI won't run until next is merged in. GitHub doesn't run pull_request workflows on a conflicting PR. Merging origin/next directly conflicts in about 25 files, because next has #1523 as one squash commit. This sequence gives a single, trivial conflict (I ran it locally on 275a0679):

git fetch upstream   # adcontextprotocol/adcp-client-python
# 1. Merge #1523's final head. It is next's tree exactly, plus the merge with next.
git merge 87f3956b6a854e3fdd810a68d92cd1a593719765
#    The only conflict is pyproject.toml: keep your "http-message-signatures>=2.1.0,<3",
#    and drop the three comment lines about the "<2.1" cap.
# 2. Record next's squash of #1523 without changing the tree (next's tree == 87f3956b's).
git merge -s ours upstream/next

After the two merges, verifier.py keeps target_uri_precheck and its call site. The signing, identity, WBA, governance and webhook suites pass with http-message-signatures 2.1.0: 2,672 passed. The only failures are #1524's 26, whose fixes are in #1539. That includes the new test_request_verifier_prechecks_wire_host_before_key_lookup_and_replay.

Two things in #1523 as merged that you'll see in the merge:

Harness pin. ADCP_REF 306cdfa6 is ahead of ac2b3ffa, so it already grades D14c, and no bump is needed. The #8298 head has since moved to c5364064, but those 29 commits are merges from main and don't touch the harness or the WBA vectors.

Once next is merged in, the full CI runs, wba-interop included. A maintainer can merge it once that's green.

🤖 Generated with Claude Code

@aao-secretariat

Copy link
Copy Markdown

Ladon cannot review this PR until merge conflicts are resolved.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — subsequent pass on an already-approved 3.2.4 regen + version-bump PR; no blocking findings.

What I checked:

  • No critical/high/medium findings in this run; prior pass had no blockers and none re-surfaced.
  • high_risk: true is driven entirely by (modified) entries under src/adcp/signing/** — all modifications, no deletions. Reviewer found no medium-or-higher concerns on those files, so the modifications are presumed safe (no Row 5 trigger).
  • gated_paths: false and review_decision: APPROVED — Row 2 not applicable.
  • No no-auto-approve team match (Row 7 N/A).
  • Verified reviewer's notes: deliberate server-side fail-closed vs buyer-side fail-open asymmetry is correct; fetcher cache bounded with cursor-cycle detection and credential-scoped keys; new public exports (adcp.identifiers, adcp.property_lists) present in __init__ __all__ + TYPE_CHECKING and documented in AGENTS.md (no missing handler-table entry); CI gates remain armed (no blanket type:ignore, no disabled tests, no dropped ruff rules).

Decision table: none of rows 1–8 fire → Row 9 approve. Net improvement / clean subsequent pass.

@bokelley
bokelley merged commit 09db189 into adcontextprotocol:next Oct 19, 2026
41 checks passed
bokelley added a commit that referenced this pull request Oct 19, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants