Skip to content

Commit 2fed413

Browse files
vvillait88claude
andcommitted
fix(ucp): round-7 review parity fixes
- verify_ucp_profile: compare signed payload against canonical body using hmac.compare_digest for constant-time equality, matching the node-commerce sibling. - UCPProfile.to_dict reserved set: replace Python dunders with __proto__ / constructor / prototype so the reserved set is byte-identical across the Node and Python SDKs (Node-signed profiles carrying those keys are rejected by both). - README: tell publishers to set Cache-Control: public, max-age=300 on /.well-known/jwks.json and wait at least that long before removing the old JWK during rotation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9c2e098 commit 2fed413

4 files changed

Lines changed: 12 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ jwks = build_jwks_response([key.public_jwk])
236236

237237
**Persisting the private JWK.** Mint once via `generate_ucp_signing_key()`, serialize via `key.private_key.as_dict(private=True)`, store in your secret manager. On each container start, read the secret, `OKPKey.import_key(jwk_dict)` (or `ECKey.import_key` for ES256) to re-hydrate. Remote-signer flows (KMS-backed asymmetric keys) require subclassing the joserfc Key to delegate the sign hook; `OKPKey`/`ECKey` themselves only carry local key material.
238238

239-
**Key rotation.** Mint a new key with a new `kid`, add the public JWK to your JWKS endpoint alongside the old one, then sign new profiles with the new key. Drop the old JWK after your verifier-side cache TTL has elapsed.
239+
**Key rotation.** Mint a new key with a new `kid`, add the public JWK to your JWKS endpoint alongside the old one, then sign new profiles with the new key. Set `Cache-Control: public, max-age=300` on `/.well-known/jwks.json` and wait at least that long after publishing the new key before removing the old JWK.
240240

241241
**Inline JWK in the profile vs separate JWKS endpoint.** UCP §6 mandates the separate `/.well-known/jwks.json` endpoint as the canonical trust source. The profile's `signing_keys[]` is informational; verifiers MUST resolve the kid against the JWKS to prevent a swap-after-sign attack.
242242

agentscore_commerce/identity/ucp.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,9 @@ def to_dict(self) -> dict[str, Any]:
183183
out["name"] = self.name
184184
# Filter `extras` so a caller passing
185185
# ``extras={"signing_keys": [...]}`` can't silently destroy the
186-
# explicit field. Reserved-field collisions are rejected at
187-
# build-time-equivalent surface. ``__class__`` / ``__dict__`` /
188-
# ``__init__`` mirror node-commerce's prototype-pollution defense
189-
# against bidirectional vendor data passing through both SDKs.
186+
# explicit field. ``__proto__`` / ``constructor`` / ``prototype``
187+
# match the node-commerce reserved set so a Node-signed profile
188+
# carrying those keys is rejected identically by both SDKs.
190189
reserved = {
191190
"version",
192191
"spec",
@@ -196,9 +195,9 @@ def to_dict(self) -> dict[str, Any]:
196195
"signing_keys",
197196
"name",
198197
"signature",
199-
"__class__",
200-
"__dict__",
201-
"__init__",
198+
"__proto__",
199+
"constructor",
200+
"prototype",
202201
}
203202
for k, v in self.extras.items():
204203
if k in reserved:

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from __future__ import annotations
2626

2727
import contextlib
28+
import hmac
2829
import json
2930
import warnings
3031
from dataclasses import dataclass
@@ -410,7 +411,7 @@ def verify_ucp_profile(
410411
# profile we received. ``deserialize_compact`` validates the JWS against the bytes
411412
# embedded in the JWS payload segment — but the profile body could have been
412413
# swapped after signing while the JWS stayed unchanged.
413-
if obj.payload != expected_payload:
414+
if not hmac.compare_digest(obj.payload, expected_payload):
414415
raise UCPVerificationError(
415416
"body_mismatch",
416417
"UCP profile body does not match the signed payload (tampered or non-canonical).",

tests/test_ucp.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ def test_respects_agentscore_schema_url_override():
124124
"signing_keys",
125125
"name",
126126
"signature",
127-
"__class__",
128-
"__dict__",
129-
"__init__",
127+
"__proto__",
128+
"constructor",
129+
"prototype",
130130
],
131131
)
132132
def test_extras_reserved_collision_rejected(key: str) -> None:

0 commit comments

Comments
 (0)