Skip to content

feat(signing): live revocation-list fetcher with signed JWS verification#201

Merged
bokelley merged 2 commits intomainfrom
bokelley/signing-revocation
Apr 19, 2026
Merged

feat(signing): live revocation-list fetcher with signed JWS verification#201
bokelley merged 2 commits intomainfrom
bokelley/signing-revocation

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Ships a production-ready CachingRevocationChecker that fetches, verifies, and caches operator-signed revocation lists from {origin}/.well-known/governance-revocations.json per security.mdx §Revocation (landed via adcp#2316). Plugs directly into the existing VerifyOptions.revocation_checker.

Closes #188.

What integrators get

from adcp.signing import CachingJwksResolver, CachingRevocationChecker, VerifyOptions

jwks = CachingJwksResolver(jwks_uri="https://gov.example.com/.well-known/jwks.json")
checker = CachingRevocationChecker.from_issuer_origin(
    "https://gov.example.com",
    jwks_resolver=jwks,
)
checker.prime()  # optional — fail-fast at startup

options = VerifyOptions(..., revocation_checker=checker)

Behavior

  • Fetch: HTTPS-only, SSRF-validated, If-None-Match + If-Modified-Since conditional requests, follow_redirects=False
  • JWS verify: compact + general-JSON serializations, EdDSA / ES256 whitelist, alg=none rejected, typ=adcp-gov-revocation+jws byte-equal match, crit extensions refused
  • Cache: lazy first fetch (or eager via prime()); refetch when now >= next_update
  • Fail-closed: past next_update + 2× declared_interval, subsequent calls raise RevocationListFreshnessError (→ request_signature_revocation_stale)
  • Replay defense: refresh refuses a list whose updated is older than the cached one
  • Cadence floor: declared next_update - updated < 60s rejected at parse (spec floor)
  • Issuer normalization: RFC 6454 origin semantics (case-insensitive scheme + host, strip trailing slash)
  • Cooldown: 60s between refresh attempts when the list is past next_update (stops a high-QPS verifier from hammering a dead origin)

Design notes

  • JWS is hand-rolled (no pyjwt / authlib). AdCP has a narrow allowed-alg set and we already own the crypto; ~150 lines against the existing primitives is leaner and more auditable than adding a dep surface for one profile.
  • JWS compact signing input uses the ORIGINAL base64url substring — not decode-then-re-encode. Guards against lenient decoders accepting non-url-safe chars or padding and round-tripping to different bytes than what the signer hashed.
  • Shared JwksResolver Protocol consolidated in adcp.signing.jwks (previously duplicated in jws.py + verifier.py).
  • Version forward-compat: accepts any positive integer; additive schema changes to the issuer shouldn't force every old SDK into fail-closed.

Review

Reviewed by code-reviewer, security-reviewer, ad-tech-protocol-expert, and dx-expert subagents. 13 follow-up fixes applied:

# Category Fix
1 Correctness Consolidated duplicate JwksResolver Protocol
2 Security Compact-form JWS signing input uses original b64 substring
3 API Dropped allow_private redundancy from Protocol
4 Security Reject new.updated < current.updated (replay block)
5 Spec Reject sub-60s declared cadence at parse
6 Spec If-Modified-Since / Last-Modified support
7 Spec is_jti_revoked() for governance step 14
8 Spec Issuer URL normalization
9 Spec Soft-accept version > 1
10 DX from_issuer_origin() classmethod
11 DX prime() for fail-fast at startup
12 DX Trimmed public surface (dropped RevocationListSignatureError, polling constants, internal JWS names)
13 DX Wire-code mapping in error docstrings

Not in this PR (follow-ups)

  • Async fetcher Protocol (AsyncRevocationListFetcher + aprime()) — the dx-expert's highest-impact suggestion. FastAPI verifiers are async; forcing sync means callers either block the loop or bridge through asyncio.run. Deferred to its own PR because it also wants a consistent pass on CachingJwksResolver.
  • TimeSource protocol consolidating clock + wall_clock. Touches CachingJwksResolver too.
  • Exponential backoff on repeated fetch failures. Current 60s cooldown is correct but simple.

Test plan

  • pytest tests/conformance/signing/ -q — 212 passed, 1 skipped
    • 22 JWS unit (round-trips + negative cases)
    • 24 fetcher + checker unit (happy path, cache, refresh, grace, cooldown, replay, all reviewer-fix defenses)
    • 5 end-to-end via real Starlette + httpx.ASGITransport against the live verify_request_signature pipeline
  • ruff check src/adcp/signing/ tests/conformance/signing/ — clean
  • mypy src/adcp/signing/ — clean (16 source files)
  • Manual: wire against a real governance agent once one is available in staging

🤖 Generated with Claude Code

@gitguardian
Copy link
Copy Markdown

gitguardian Bot commented Apr 19, 2026

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Adds production-ready revocation checking per security.mdx §Revocation
(landed via adcp#2316). Callers wire a `CachingRevocationChecker` into
`VerifyOptions.revocation_checker`; it fetches
`{origin}/.well-known/governance-revocations.json`, verifies the
operator's JWS, caches the parsed list, refetches near `next_update`,
and fails closed past `next_update + grace * last_interval`.

New public surface (`adcp.signing`):

* `CachingRevocationChecker` — implements the existing
  `RevocationChecker` Protocol. Convenience constructor
  `.from_issuer_origin("https://gov.example.com")` pins the .well-known
  path. `.prime()` fetches synchronously so integrators can fail-fast
  at startup rather than at first verification. `.is_jti_revoked(jti)`
  exposes the per-token revocation surface governance verifiers need.
* `RevocationListFetcher` Protocol + `default_revocation_list_fetcher`
  with SSRF validation (reuses JWKS rules), `If-None-Match` and
  `If-Modified-Since` conditional requests, `follow_redirects=False`.
* `verify_jws_document` + compact / general-JSON parsers. Hand-rolled
  against the existing crypto primitives (EdDSA / ES256 whitelist,
  `alg=none` rejection, byte-equal `typ` match, `crit` refused) —
  auditable without pulling in pyjwt / authlib.
* Shared `JwksResolver` Protocol consolidated in `adcp.signing.jwks`
  (was duplicated in `jws.py` + `verifier.py`).

Defense-in-depth (applied after expert review):

* JWS compact-form signing input uses the ORIGINAL base64url substring,
  not decode-then-re-encode — guards against lenient-decode mismatches.
* Refresh rejects a list whose `updated` is older than the cached one.
  Blocks replay of older lists after a kid has been revoked.
* Parse rejects declared cadences below the 60s spec floor.
* Issuer comparison is case-insensitive on scheme + host and strips
  trailing slash / path (RFC 6454 origin semantics).
* Version check soft-accepts any positive integer so additive schema
  changes don't force the library into fail-closed on old SDKs.

Tests (+74 across four files, 212 signing tests total):
* `test_jws.py` (22) — parse/verify round-trips, negative cases
  (`alg=none`, wrong `typ`, missing `kid`, `crit`, tampered payload,
  non-JSON / non-dict payload).
* `test_revocation_fetcher.py` (24) — happy path, cache hit, past-
  `next_update` refresh, 304, signature tampering, wrong issuer,
  cadence-floor rejection, clock skew, grace window (within + past),
  general-JSON form, cooldown on failure, replay rejection,
  `from_issuer_origin`, `prime()`, `is_jti_revoked`, case-variant
  issuer acceptance, `If-Modified-Since` threading, alternate-encoding
  rejection.
* `test_revocation_e2e.py` (5) — real Starlette app via
  `httpx.ASGITransport`, plugs into `verify_request_signature`; covers
  kid-revoked → 401, kid-clean → 200, past-grace → freshness error.

Closes #188.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/signing-revocation branch from 2912d5b to fbe3e90 Compare April 19, 2026 16:02
…t_update on 304

Expert re-review of PR #201 surfaced three load-bearing issues beyond the
original 13-item fix pass. All addressed here plus defensive tests.

Security
--------
* Issuer normalization rejects userinfo (``user:pass@host``) and
  IDNA-encodes the host so homoglyph attacks (``go\u1d20.example.com``)
  produce a byte-distinct punycode form that fails comparison against a
  legitimately-configured origin.
* ``Last-Modified`` header values are now shape-validated (RFC 7231
  HTTP-date regex + 64-byte cap) before being persisted for the next
  ``If-Modified-Since`` request. Blocks CRLF-injection-adjacent echoing
  of a malicious origin's header into our outbound requests.

Behavior
--------
* On 304, advance the cached ``next_update`` by the declared polling
  interval. Previously a steady-state 304 would leave ``next_update`` at
  the original value, causing every subsequent verification to retry the
  refresh logic (gated by 60s cooldown but still wasted work). Now the
  freshness window slides forward and the hot path short-circuits.

Safety / DX
-----------
* Document single-threaded contract on ``CachingRevocationChecker``
  (defer lock until a threaded caller actually lands).
* Constructor rejects ``time.time`` passed as ``clock`` (wall-clock
  jumps would break cooldown math) and rejects passing identical
  callables to ``clock`` and ``wall_clock``.
* ``RevocationListSignatureError`` now documented as explicitly NOT
  re-exported from ``adcp.signing`` (still importable from the submodule
  for callers that want to distinguish signature from parse failures).
* Cross-link ``__call__`` and ``is_jti_revoked`` docstrings so
  governance-token verifiers find the jti surface.
* Stronger replay-rejection assertion pins the "cached list not
  replaced" invariant explicitly.

Tests (+7): userinfo rejection, homoglyph distinct-punycode, legit IDN
stability, Last-Modified sanitization including CRLF + length cap, 304
sliding next_update forward, time.time clock rejection, identical-
callable clock rejection. Total signing suite now 219 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 249b79d into main Apr 19, 2026
8 checks passed
bokelley added a commit that referenced this pull request Apr 19, 2026
Adds async counterparts to the sync types that landed in PR #201 so
verifiers running on an asyncio event loop (FastAPI, Starlette, etc.)
don't need to bridge through ``asyncio.run`` or block the loop on
revocation/JWKS fetches. Addresses the dx-expert's top DX callout on
PR #201.

New public surface (``adcp.signing``)
-------------------------------------

* ``AsyncJwksResolver`` / ``AsyncJwksFetcher`` Protocols
* ``AsyncCachingJwksResolver`` with ``asyncio.Lock``-serialized refreshes
* ``async_default_jwks_fetcher`` using ``httpx.AsyncClient``
* ``as_async_resolver(sync)`` adapter so ``StaticJwksResolver`` can plug
  into async pipelines
* ``AsyncRevocationListFetcher`` Protocol
* ``async_default_revocation_list_fetcher``
* ``AsyncCachingRevocationChecker`` — mirrors ``CachingRevocationChecker``
  with awaitable ``__call__``, ``aprime()``, awaitable ``is_jti_revoked``,
  ``from_issuer_origin`` classmethod. ``asyncio.Lock`` serializes
  concurrent refreshes so N parallel first-miss tasks fire one fetch.
* ``averify_detached_jws`` / ``averify_jws_document``

Refactor
--------

Sync and async paths share:

* ``_post_jws_validation`` — schema + clock-skew + replay check
* ``_slide_next_update`` — 304 response handling
* ``_polling_interval_seconds`` — clamped interval math
* ``_build_fetch_headers`` / ``_fetch_result_from_response`` — HTTP glue
* ``_validate_header`` / ``_verify_signature_and_decode_payload`` —
  JWS pipeline split at the resolver call
* ``_CheckerState`` mixin — cache state + ``_handle_not_modified`` /
  ``_commit`` / ``_grace_seconds`` methods inherited by both checkers

Preserves sync behavior byte-for-byte; sync test suite unchanged at 219.

Defense-in-depth (from expert re-review)
----------------------------------------

* Eager ``asyncio.Lock()`` construction in ``__init__``. Lazy init was
  racy — two tasks could each see ``self._lock is None`` and construct
  separate locks. Python 3.10+ no longer requires a running loop for
  ``asyncio.Lock()``.
* Cancellation-safe ``_last_refresh_attempt`` — rolls back on
  ``CancelledError`` so a cancelled task doesn't burn the 60s cooldown
  for the next legitimate caller (DoS-amplification defense).
* Clock reads recomputed inside the lock — the pre-lock reads could be
  stale after a slow refresh.

Tests (+20)
-----------

``test_async_revocation.py`` — 20 tests mirroring sync coverage plus:

* ``test_concurrent_first_calls_share_one_refresh`` — 20 tasks →
  exactly 1 refresh (regression test for the lazy-lock race)
* ``test_cancellation_rolls_back_cooldown_attempt`` — pins the
  cancellation-safety invariant
* ``test_async_e2e_asgi_round_trip`` — native Starlette +
  ``httpx.AsyncClient`` e2e, NO ``asyncio.run`` bridge (the whole
  point of the async path)
* ``test_async_caching_jwks_resolver_handles_concurrent_misses`` —
  async JWKS lock serialization

239 signing tests pass, ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 (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.

Signing: live revocation-list fetcher with freshness check

1 participant