Skip to content

Commit 3013392

Browse files
vvillait88claude
andcommitted
hardening(identity): reject U+2028/U+2029 in profile canonicalization
Mirrors the rejection in core/api/src/lib/canonicalize.ts so the SDK canonicalizer stays byte-symmetric across the Node and Python siblings on any pre-ES2019 V8 (where JSON.stringify still escapes these codepoints) or browser-side verifier path; today's V8 emits them raw, so the divergence is theoretical but the contract is now consistent with the upstream check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 81ffd00 commit 3013392

2 files changed

Lines changed: 86 additions & 2 deletions

File tree

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ def generate_ucp_signing_key(*, kid: str, alg: Literal["EdDSA", "ES256"] = "EdDS
162162

163163

164164
def _reject_unsafe_numbers(value: Any) -> None:
165-
"""Walk ``value`` and raise on any number that won't survive cross-language parity.
165+
"""Walk ``value`` and raise on anything that won't survive cross-language parity.
166166
167-
Two failure modes are rejected:
167+
Three failure modes are rejected:
168168
169169
* Non-integer ``float`` values. Cross-language float canonicalization (RFC 8785
170170
§3.2.2.3) diverges between Python's ``json.dumps`` and Node's ``JSON.stringify``
@@ -174,6 +174,12 @@ def _reject_unsafe_numbers(value: Any) -> None:
174174
Python ints are arbitrary-width, but JS verifiers parse the canonical body via
175175
``JSON.parse`` which silently loses precision past 2^53. Use a decimal string
176176
for any integer that may exceed the safe range.
177+
* Strings containing U+2028 (LINE SEPARATOR) or U+2029 (PARAGRAPH SEPARATOR).
178+
Pre-ES2019 V8 (and any environment whose ``JSON.stringify`` still escapes
179+
these codepoints) emits the escaped sequences while
180+
``json.dumps(ensure_ascii=False)`` emits them raw, so the canonical bytes
181+
would diverge across the Node and Python siblings. Mirror of the rejection
182+
in ``core/api/src/lib/canonicalize.ts``.
177183
178184
Catching the drift at sign-time prevents silent verifier-side failures in
179185
production.
@@ -194,6 +200,17 @@ def _reject_unsafe_numbers(value: Any) -> None:
194200
"parse this; use a decimal string to preserve cross-language byte-parity."
195201
)
196202
raise ValueError(msg)
203+
if isinstance(value, str):
204+
if "\u2028" in value or "\u2029" in value:
205+
msg = (
206+
"UCP profile strings containing U+2028 (LINE SEPARATOR) or "
207+
"U+2029 (PARAGRAPH SEPARATOR) are not allowed; cross-language "
208+
"byte parity requires neither be present (Node JSON.stringify "
209+
"on older V8 escapes them; Python json.dumps with "
210+
"ensure_ascii=False does not)."
211+
)
212+
raise ValueError(msg)
213+
return
197214
if isinstance(value, dict):
198215
for k, v in value.items():
199216
_reject_unsafe_numbers(k)

tests/test_ucp_jwks.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,73 @@ def test_sign_accepts_bool_dict_keys(self) -> None:
671671
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
672672

673673

674+
# U+2028 / U+2029 named via escape so the RUF001 ambiguous-character lint
675+
# doesn't fire on the test inputs (the codepoints are intentional, not typos).
676+
_U2028 = "\u2028"
677+
_U2029 = "\u2029"
678+
679+
680+
class TestLineParagraphSeparatorRejection:
681+
"""U+2028 / U+2029 are escaped by pre-ES2019 V8 (``JSON.stringify`` emits
682+
the escaped sequences) but emitted raw by ``json.dumps(ensure_ascii=False)``.
683+
684+
Modern V8 emits them raw too, so the divergence is theoretical on today's
685+
Node, but the rejection mirrors core/api/src/lib/canonicalize.ts so the
686+
contract stays symmetric for any pre-ES2019 verifier path (older V8,
687+
browser-side verifier code).
688+
"""
689+
690+
def test_rejects_u2028_at_top_level(self) -> None:
691+
signer = generate_ucp_signing_key(kid="k")
692+
profile = {**_base_profile([signer.public_jwk]), "extras": {"note": f"before{_U2028}after"}}
693+
with pytest.raises(ValueError, match="U\\+2028"):
694+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
695+
696+
def test_rejects_u2029_at_top_level(self) -> None:
697+
signer = generate_ucp_signing_key(kid="k")
698+
profile = {**_base_profile([signer.public_jwk]), "extras": {"note": f"before{_U2029}after"}}
699+
with pytest.raises(ValueError, match="U\\+2029"):
700+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
701+
702+
def test_rejects_u2028_nested_in_list(self) -> None:
703+
signer = generate_ucp_signing_key(kid="k")
704+
profile = {**_base_profile([signer.public_jwk]), "extras": {"items": ["ok", f"bad{_U2028}tail"]}}
705+
with pytest.raises(ValueError, match="U\\+2028"):
706+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
707+
708+
def test_rejects_u2029_nested_in_list(self) -> None:
709+
signer = generate_ucp_signing_key(kid="k")
710+
profile = {**_base_profile([signer.public_jwk]), "extras": {"items": ["ok", f"bad{_U2029}tail"]}}
711+
with pytest.raises(ValueError, match="U\\+2029"):
712+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
713+
714+
def test_rejects_u2028_nested_in_dict_value(self) -> None:
715+
signer = generate_ucp_signing_key(kid="k")
716+
profile = {**_base_profile([signer.public_jwk]), "extras": {"deep": {"inner": f"before{_U2028}after"}}}
717+
with pytest.raises(ValueError, match="U\\+2028"):
718+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
719+
720+
def test_rejects_u2029_nested_in_dict_value(self) -> None:
721+
signer = generate_ucp_signing_key(kid="k")
722+
profile = {**_base_profile([signer.public_jwk]), "extras": {"deep": {"inner": f"before{_U2029}after"}}}
723+
with pytest.raises(ValueError, match="U\\+2029"):
724+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
725+
726+
def test_rejects_u2028_in_dict_key(self) -> None:
727+
signer = generate_ucp_signing_key(kid="k")
728+
profile = {**_base_profile([signer.public_jwk]), "extras": {f"bad{_U2028}key": "value"}}
729+
with pytest.raises(ValueError, match="U\\+2028"):
730+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
731+
732+
def test_accepts_u2027_sanity_case(self) -> None:
733+
# U+2027 (HYPHENATION POINT) is a different codepoint, not a target of
734+
# the rejection. Confirms we're matching exactly U+2028 / U+2029.
735+
signer = generate_ucp_signing_key(kid="k")
736+
profile = {**_base_profile([signer.public_jwk]), "extras": {"note": "before\u2027after"}}
737+
signed = sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
738+
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
739+
740+
674741
class TestVerifierErrorPrecedence:
675742
def test_null_profile_with_malformed_jwks_returns_no_signature(self) -> None:
676743
with pytest.raises(UCPVerificationError) as exc:

0 commit comments

Comments
 (0)