Skip to content

Commit a016eca

Browse files
bokelleyclaude
andauthored
feat(signing): v3-identity Tier 1 — BrandJsonJwksResolver + CapabilityCache (port from JS) (#345)
* feat(signing): port BrandJsonJwksResolver + CapabilityCache + capability priming from JS Tier 1 of the v3-identity bundle (RFC at docs/proposals/v3-identity-bundle-design.md). Direct port of three JS-side modules: * BrandJsonJwksResolver — implements adcp.signing.AsyncJwksResolver. Walks brand.json/agents[], follows authoritative_location/house redirects with bare-host validation, picks agent by (type, id?, brand_id?), falls back to origin-bound /.well-known/jwks.json (security: prevents cross-origin trust pivot), honors Cache-Control + ETag, cascade refresh on unknown kid. 10 typed error codes match JS for cross-language conformance. Composes with AsyncCachingJwksResolver for inner JWK caching — does NOT reinvent that layer. * CapabilityCache + build_capability_cache_key — TTL-based per-agent cache for request_signing capability blocks. Cache-key format byte-identical to JS so a future shared Redis cache works cross-language. * ensure_capability_loaded — async primer with fail-open negative- cache TTL (60s) on fetch failures, in-flight dedup via asyncio.Future, MCP/A2A transport unwrapping (structuredContent, content[].text, result.artifacts[].parts[].data, result.parts[].data). This is the JWKS-resolution layer of the v3 trust chain. The verifier machinery already exists in adcp.signing/ (verify_starlette_request, AsyncCachingJwksResolver, ReplayStore, etc.); this connects the brand-side discovery to the existing verifier. Composes with future Tier 2 (BuyerAgentRegistry per adcontextprotocol/adcp-client#1269) and Tier 3 (BrandAuthorizationResolver, gated on adcontextprotocol/adcp#3690 for eTLD+1 binding + authorized_operators[]). Tests: 61 new (34 brand_jwks, 27 capability_cache + priming), 2,975 total pass. ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(signing): address expert review on PR #345 — SSRF, body cap, URL canon Six findings from the four expert reviews on Tier 1 JWKS port: **Critical (security):** * SSRF on brand.json fetch — brand.json walker now uses ``build_async_ip_pinned_transport`` per hop with ``trust_env=False``, matching the JWKS fetcher's posture. Without this, an attacker-controlled ``authoritative_location`` could redirect the fetch chain to private IPs / cloud-metadata endpoints. The brand.json walker is the verifier's trust root — its SSRF posture must be at least as strict as JWKS. * No response-body size cap — counterparty serving a multi-megabyte brand.json would OOM the verifier. Added ``DEFAULT_MAX_BRAND_JSON_BYTES`` (256 KiB) cap, rejected before parse with ``invalid_body``. **Critical (correctness):** * URL canonicalization gap — ``urlsplit`` lowercases scheme but NOT host, and does not strip default ports. JS ``new URL()`` does both. Without these, the redirect-loop detector saw ``https://X.example/`` and ``https://x.example/`` as distinct entries; the well-known fallback origin check spuriously rejected ``https://x:443`` vs ``https://x``. Fixed by normalizing host to lowercase and stripping default ports (443 for https, 80 for http). **Real bugs (concurrency):** * Refresh single-flighting was serializing — replaced ``asyncio.Lock`` with a Future-based dedup, mirroring the JS pattern. N concurrent ``resolve()`` calls on a cold cache now share ONE brand.json fetch instead of doing N fetches in series. * ``Future`` lifecycle on ``BaseException`` in ``ensure_capability_loaded`` — the bare ``except Exception`` previously swallowed most errors but let ``BaseException`` (including ``CancelledError``) propagate without resolving the in-flight future, leaving joined waiters hung forever. Now narrowed to expected discovery errors; BaseException propagates with the future explicitly excepted. **Real bugs (DX):** * Capability-priming silent error swallow — bare ``except Exception`` meant operators had no signal when a counterparty's discovery endpoint was poisoning their negative cache. Added WARNING-level logging on every fail-open path. **Tests:** * Tests now use a ``client_factory`` injection seam instead of monkey-patching ``httpx.AsyncClient.__init__`` globally — only resolvers built in the test scope pick up the mock; concurrent AsyncClient construction for unrelated reasons is unaffected. * Added 7 new tests covering: host case lowering, default port stripping, scheme lowering, non-default port preservation, oversized-body rejection, case-aliasing redirect-loop detection, and N-concurrent-resolve fetch dedup. 68 tests pass (up from 61), 2960 total. ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4bd34e9 commit a016eca

7 files changed

Lines changed: 3039 additions & 0 deletions

File tree

docs/proposals/v3-identity-bundle-design.md

Lines changed: 659 additions & 0 deletions
Large diffs are not rendered by default.

src/adcp/signing/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,30 @@
9292
SigningDecision,
9393
operation_needs_signing,
9494
)
95+
from adcp.signing.brand_jwks import (
96+
BrandAgentType,
97+
BrandJsonJwksResolver,
98+
BrandJsonResolverError,
99+
BrandJsonResolverErrorCode,
100+
)
95101
from adcp.signing.canonical import (
96102
SignatureInputLabel,
97103
build_signature_base,
98104
canonicalize_authority,
99105
canonicalize_target_uri,
100106
parse_signature_input_header,
101107
)
108+
from adcp.signing.capability_cache import (
109+
CachedCapability,
110+
CapabilityCache,
111+
build_capability_cache_key,
112+
default_capability_cache,
113+
)
114+
from adcp.signing.capability_priming import (
115+
CAPABILITY_OP,
116+
NEGATIVE_CACHE_TTL_SECONDS,
117+
ensure_capability_loaded,
118+
)
102119
from adcp.signing.client import (
103120
CapabilityProvider,
104121
install_signing_event_hook,
@@ -255,8 +272,15 @@ def __init__(self, *args: object, **kwargs: object) -> None:
255272
"AsyncJwksFetcher",
256273
"AsyncJwksResolver",
257274
"AsyncRevocationListFetcher",
275+
"BrandAgentType",
276+
"BrandJsonJwksResolver",
277+
"BrandJsonResolverError",
278+
"BrandJsonResolverErrorCode",
279+
"CAPABILITY_OP",
280+
"CachedCapability",
258281
"CachingJwksResolver",
259282
"CachingRevocationChecker",
283+
"CapabilityCache",
260284
"CapabilityProvider",
261285
"DEFAULT_ALLOWED_PORTS",
262286
"DEFAULT_EXPIRES_IN_SECONDS",
@@ -273,6 +297,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
273297
"JwsSignatureInvalidError",
274298
"JwsUnknownKeyError",
275299
"MAX_WINDOW_SECONDS",
300+
"NEGATIVE_CACHE_TTL_SECONDS",
276301
"NONCE_BYTES",
277302
"PgReplayStore",
278303
"REQUEST_SIGNATURE_ALG_NOT_ALLOWED",
@@ -325,14 +350,17 @@ def __init__(self, *args: object, **kwargs: object) -> None:
325350
"b64url_decode",
326351
"b64url_encode",
327352
"build_async_ip_pinned_transport",
353+
"build_capability_cache_key",
328354
"build_ip_pinned_transport",
329355
"build_signature_base",
330356
"canonicalize_authority",
331357
"canonicalize_target_uri",
332358
"compute_content_digest_sha256",
333359
"content_digest_matches",
360+
"default_capability_cache",
334361
"default_jwks_fetcher",
335362
"default_revocation_list_fetcher",
363+
"ensure_capability_loaded",
336364
"extract_signature_bytes",
337365
"format_signature_header",
338366
"generate_signing_keypair",

0 commit comments

Comments
 (0)