Repository navigation
fix(governance): require crit markers with their claims, and run the signing tests CI skips - #1539
Conversation
…ests operation_id became a required keyword of WebhookSender.send_mcp in adcontextprotocol#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 (adcontextprotocol#1524). Each call now passes operation_id.
…od the sender calls Since adcontextprotocol#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.
…pe 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.
Since adcontextprotocol#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_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.
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 (adcontextprotocol#1524). The dev extra now carries both packages, so each job
that runs the signing suite runs these files too.
Closes adcontextprotocol#1524
…s 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.
…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.
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.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Clean fix PR with three correctness/security improvements in the governance + signing verifiers, each driven from a failing test, plus a CI-coverage fix that unmasks six signing modules previously skipped by pytest.importorskip (fastapi/svix moved to the dev extra).
Checked:
- Strict
b64url_decode(crypto.py) — correct RFC 7515/4648 implementation (refuses=/+//,validate=True, roundtrip rejects non-zero trailing bits), consistent with_COMPACT_SEGMENT_RE, covered by four parametrized spoilers. Narrowing the publicb64url_decodeis a documented, defensible semver call (conformance defect, 9.x beta, migration note in PR body). type(alg) is not str(governance.py) — closes an uncaughtTypeErroron attacker-controlled tokens; now a cleangovernance_token_invalid.- Critical-marker chained-equality correctly enforces marker/crit-entry/claim coherence per security.mdx step 4.
operation_id="op_test"additions are not a new break —send_mcphas required it since #1066; these modules simply never ran before.
high_risk is true only because crypto.py was modified; reviewer found no medium-or-higher concerns, so the modification is presumed safe (no row-5 trigger). gated_paths is false and no no-auto-approve team match. No Critical/High/Medium findings → falls through to row 9.
|
I checked this PR at
One fix before merge. The description's Testing section still holds the template placeholder Sync to This covers the 🤖 Generated with Claude Code |
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>
|
Thanks for landing this. I reproduced the same three incoherent marker/claim shapes in AdCP's shared-vector oracle at I'll prepare a separate, small AdCP follow-up adding those cases and tightening the test oracle under the existing step-4 rule. This won't touch the Python changes or the 3.3 WBA key-directory migration. Please let me know if that shared-vector slice is already in flight. |
Closes #1524.
This pull request carries the AdCP 3.2 fixes from #1537 to
main, where RELEASING.md says fixes land first.nextpicks them up through the regular sync.Changes
The commits make the following changes:
test(webhooks),test(signing), five commits). Withfastapiandsvixinstalled, 26 tests in six signing files fail onmain(Six signing test files never run in CI, and 26 of their tests fail on main #1524). The commits fix the tests, not the SDK:operation_idtosend_mcp, which feat(webhooks): add durable task webhook outbox #1066 made required (24 tests).streammethod that the sender calls since feat(webhooks): add durable task webhook outbox #1066.409.WebhookSender.resendexpects.content-digest(feat(protocol)!: stabilize multi-version SDK 8 beta #1038), as AdCP 3.2 request signing requires.ci). Thedevextra addsfastapiandsvix. Every CI job installs.[dev]or.[dev,pg], so the five modules that skip onpytest.importorskip("fastapi"), and the twosvixinterop tests intest_standard_webhooks.py, run in CI.fix(governance)).verify_governance_authorizationcompared each claim only with acritentry whose marker istrue. So it accepted a marker for a claim the payload doesn't carry: acritentry with afalsemarker, a marker with nocritentry, or afalsemarker alone. The marker, itscritentry, and its claim must now appear together, with the markertrue.alg(fix(governance)). A headeralgthat is a JSON list or object raisedTypeError, because the verifier tested it for membership in a set. It is nowgovernance_token_invalid, like the other malformed headers.fix(signing)). 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_decodeused the standard library's lenient decoder: it accepted padding and the standard alphabet's+and/, dropped other characters, and ignored trailing bits. Verifier step 8 decodes the signer's JWK throughpublic_key_from_jwk, so the request and webhook verifiers accepted a JWK whosexhad!!inserted.b64url_decodenow refuses=,+, and/, decodes the rest withbase64.b64decode(altchars=b"-_", validate=True), which refuses any other character, and refuses non-zero trailing bits (RFC 4648 section 3.5), so each value has the one spelling thatb64url_encodeproduces. The fix uses the standard library;maindoesn't depend onjoserfc.Each fix started from a failing test:
test_critical_marker_crit_entry_and_claim_appear_togetherre-signs the accepted governance vector with each header, so only thecritcheck can refuse it. Before the fix, all three cases passed thecritcheck and failed later withgovernance_token_not_applicable.test_a_non_string_alg_is_an_invalid_tokenraisedTypeErrorfor both cases.test_a_re_signed_vector_is_acceptedchecks that the re-signing helper produces a token the verifier accepts.test_jwk_members_are_strict_base64urlfailed for all four spellings: characters outside the alphabet, the standard alphabet, padding, and non-zero trailing bits.Spec basis
security.mdx, governance verification checklist step 4, at AdCP 3.2.4 and 3.2.5: "Forauthorized_commitment,authorized_task, andauthorized_payload_hash, require each boolean protected-header marker, itscritentry, and its payload claim to appear together." The unknown-extension rule adds: "Verifiers MUST also reject when the critical marker and its corresponding claim do not appear together." Ungraded: no case ingovernance-authorization.jsonsends an incoherent marker.API change
adcp.signing.b64url_decodeis exported, and it now refuses input that it used to decode. It ships as afixwith no breaking-change marker: accepting data outside the base64url alphabet was a conformance defect (RFC 4648 section 3.3), and 9.x is in beta (9.0.0b4). A key published with padded or standard-alphabet base64 no longer verifies.Testing
CI runs every job for this pull request, because it changes
pyproject.toml. The following ran locally on macOS, on Python 3.12.9 and 3.10.20:{{TESTING}}