Skip to content

Commit 4373205

Browse files
vvillait88claude
andcommitted
hardening: round-5 reviewer findings (alg-mismatch + warning scope + non-ASCII keys fixture)
verify_ucp_profile now rejects matched JWKs whose declared alg disagrees with the JWS protected header alg (RFC 7517 §4.4: a JWK with `alg` constrains its use to that algorithm). Closes a small security gap where a JWKS publisher advertising the wrong alg on a key would silently let mismatched signatures through joserfc's verify path. _suppress_joserfc_eddsa_warning docstring corrected. The previous text claimed joserfc emits the RFC-9864 SecurityWarning at every call including key generation; empirically the warning fires only at JWS sign/verify (in the JWS registry), not at OKPKey.generate_key. Removed the redundant suppression wrapper around generate_key in generate_ucp_signing_key — it was a no-op. New cross-language fixture py-emoji-keys.json mirrors the node-emoji-keys.json fixture landing in node-commerce. Locks codepoint-aware key sort: Python's default sorted() orders by Unicode codepoint, JS default sort orders by UTF-16 code units which diverges for supplementary-plane chars (e.g. U+1F377). The fixture covers BMP CJK Compatibility (U+8C48), non-BMP wine glass (U+1F377), and ASCII so both languages must explicitly sort by codepoint to maintain byte parity. Tests: 786 pass + 3 skipped, 95.17% coverage. ruff + ty clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 757dedf commit 4373205

4 files changed

Lines changed: 88 additions & 9 deletions

File tree

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@
4343

4444
@contextlib.contextmanager
4545
def _suppress_joserfc_eddsa_warning() -> Iterator[None]:
46-
"""Silence joserfc's exact RFC 9864 EdDSA SecurityWarning.
46+
"""Suppress joserfc's RFC-9864-deprecation SecurityWarning around JWS sign/verify.
4747
48-
UCP §6 requires EdDSA support and joserfc emits a per-call deprecation
49-
warning. The filter is pinned to the exact message + class
48+
joserfc emits this on every JWS operation that uses EdDSA, despite EdDSA
49+
being the actively-recommended-by-IETF algorithm for new deployments. The
50+
filter is pinned to the exact message + class
5051
(``joserfc.errors.SecurityWarning``: ``"EdDSA is deprecated via RFC 9864"``)
51-
so a future, unrelated EdDSA warning still surfaces normally.
52+
so any other SecurityWarning still surfaces normally. Key generation does
53+
not emit this warning, so suppression has no effect there.
5254
"""
5355
from joserfc.errors import SecurityWarning # type: ignore[import-not-found]
5456

@@ -138,8 +140,7 @@ def generate_ucp_signing_key(*, kid: str, alg: Literal["EdDSA", "ES256"] = "EdDS
138140
if alg == "EdDSA":
139141
from joserfc.jwk import OKPKey # type: ignore[import-not-found]
140142

141-
with _suppress_joserfc_eddsa_warning():
142-
priv = OKPKey.generate_key(crv="Ed25519", parameters={"kid": kid, "alg": alg, "use": "sig"})
143+
priv = OKPKey.generate_key(crv="Ed25519", parameters={"kid": kid, "alg": alg, "use": "sig"})
143144
elif alg == "ES256":
144145
from joserfc.jwk import ECKey # type: ignore[import-not-found]
145146

@@ -357,13 +358,22 @@ def verify_ucp_profile(
357358
"duplicate_kid",
358359
f"JWKS contains {len(matches)} keys with kid={kid!r}; expected exactly one.",
359360
)
361+
matched = matches[0]
360362
# RFC 7517 §4.2: reject keys not intended for signature verification.
361-
matched_use = matches[0].get("use")
363+
matched_use = matched.get("use")
362364
if matched_use is not None and matched_use != "sig":
363365
raise UCPVerificationError(
364366
"unusable_key",
365367
f"JWK with kid={kid!r} has use={matched_use!r}; expected 'sig'.",
366368
)
369+
# RFC 7517 §4.4: a JWK with declared `alg` constrains its use to that algorithm.
370+
header_alg = header.get("alg")
371+
matched_alg = matched.get("alg")
372+
if matched_alg is not None and matched_alg != header_alg:
373+
raise UCPVerificationError(
374+
"unusable_key",
375+
f"JWK alg {matched_alg!r} does not match JWS header alg {header_alg!r}.",
376+
)
367377

368378
stripped = {k: v for k, v in signed_profile.items() if k != "signature"}
369379
expected_payload = _canonicalize_profile(stripped)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"profile": {
3+
"version": "2026-04-17",
4+
"spec": "https://ucp.dev/",
5+
"name": "Emoji Keys Merchant",
6+
"services": [
7+
{
8+
"type": "rest",
9+
"url": "https://emoji.example.com"
10+
}
11+
],
12+
"capabilities": [],
13+
"payment_handlers": [
14+
{
15+
"name": "tempo",
16+
"config": {}
17+
}
18+
],
19+
"signing_keys": [
20+
{
21+
"crv": "Ed25519",
22+
"x": "CX-4oqEqpxhUtsTrsaF2df7KBeIR0Wpe4bgwnlsMk8A",
23+
"kid": "py-emoji-keys-EdDSA",
24+
"alg": "EdDSA",
25+
"use": "sig",
26+
"kty": "OKP"
27+
}
28+
],
29+
"extras": {
30+
"豈": 1,
31+
"🍷": 2,
32+
"a": 3
33+
},
34+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWVtb2ppLWtleXMtRWREU0EiLCJ0eXAiOiJ1Y3AtcHJvZmlsZStqd3MifQ.eyJjYXBhYmlsaXRpZXMiOltdLCJleHRyYXMiOnsiYSI6Mywi6LGIIjoxLCLwn423IjoyfSwibmFtZSI6IkVtb2ppIEtleXMgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjpbeyJjb25maWciOnt9LCJuYW1lIjoidGVtcG8ifV0sInNlcnZpY2VzIjpbeyJ0eXBlIjoicmVzdCIsInVybCI6Imh0dHBzOi8vZW1vamkuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoicHktZW1vamkta2V5cy1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJDWC00b3FFcXB4aFV0c1Ryc2FGMmRmN0tCZUlSMFdwZTRiZ3dubHNNazhBIn1dLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LyIsInZlcnNpb24iOiIyMDI2LTA0LTE3In0.aXPJHy4hcLxAF1zd9zLSZbbSMBP56BTeZVXY3V_Ywv4sqabLWgJGRmmp2iyJNamCFgYJ8jPIfd9nF1UU2R9WBg"
35+
},
36+
"jwks": {
37+
"keys": [
38+
{
39+
"crv": "Ed25519",
40+
"x": "CX-4oqEqpxhUtsTrsaF2df7KBeIR0Wpe4bgwnlsMk8A",
41+
"kid": "py-emoji-keys-EdDSA",
42+
"alg": "EdDSA",
43+
"use": "sig",
44+
"kty": "OKP"
45+
}
46+
]
47+
},
48+
"alg": "EdDSA",
49+
"kid": "py-emoji-keys-EdDSA",
50+
"generator": "python"
51+
}

tests/test_ucp_cross_lang.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ def test_corpus_covers_canonical_scenarios() -> None:
3030
generators = {json.loads(p.read_text())["generator"] for p in FIXTURES}
3131
assert "node" in generators
3232
assert "python" in generators
33-
# Each language ships 6 scenarios so cross-lang verify exercises all of them.
33+
# Each language ships 6 base scenarios so cross-lang verify exercises all of them.
3434
for lang in ("node", "py"):
3535
for scenario in ("minimal", "es256-rails", "extras-int", "capability", "unicode", "multikey"):
3636
assert f"{lang}-{scenario}.json" in names, f"missing fixture {lang}-{scenario}.json"
37-
assert len(FIXTURES) == 12
37+
# `py-emoji-keys.json` locks codepoint-aware key sort: Python sorts by Unicode
38+
# codepoint by default, JS default sort orders by UTF-16 code units which
39+
# diverges for supplementary-plane chars. The signed body covers BMP CJK
40+
# Compatibility (U+8C48), non-BMP wine glass (U+1F377), and ASCII so both
41+
# languages must explicitly sort by codepoint to maintain byte parity.
42+
assert "py-emoji-keys.json" in names

tests/test_ucp_jwks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,19 @@ def test_verify_rejects_unusable_key_use_enc(self) -> None:
389389
verify_ucp_profile(signed, build_jwks_response([enc_jwk]))
390390
assert exc.value.code == "unusable_key"
391391

392+
def test_verify_rejects_unusable_key_alg_mismatch(self) -> None:
393+
key = generate_ucp_signing_key(kid="k")
394+
profile = _base_profile([key.public_jwk])
395+
signed = sign_ucp_profile(profile, signing_key=key.private_key, kid="k")
396+
# JWKS advertises the same kid but with a wrong `alg` (RFC 7517 §4.4 violation):
397+
# JWS header carries alg=EdDSA, JWK declares alg=ES256.
398+
wrong_alg_jwk = {**key.public_jwk, "alg": "ES256"}
399+
with pytest.raises(UCPVerificationError) as exc:
400+
verify_ucp_profile(signed, build_jwks_response([wrong_alg_jwk]))
401+
assert exc.value.code == "unusable_key"
402+
assert "ES256" in str(exc.value)
403+
assert "EdDSA" in str(exc.value)
404+
392405
@pytest.mark.parametrize("bad_sig", [42, None, [], {}])
393406
def test_verify_rejects_non_string_signature(self, bad_sig: object) -> None:
394407
key = generate_ucp_signing_key(kid="k")

0 commit comments

Comments
 (0)