Skip to content

Commit ea80759

Browse files
vvillait88claude
andcommitted
hardening: round-9 reviewer findings (int-boundary cross-lang parity)
Close cross-language byte-parity hole: Python's _reject_floats only walked for float, while Node's stableStringify hard-rejects integers outside Number.MAX_SAFE_INTEGER (2^53 - 1). A Python-signed profile with an oversized int produced a valid self-verifying envelope that Node could not parse. Sign-time rejection on the Python side now matches Node, plus a checked-in cross-lang fixture (int-boundary) covers the safe-edge integer for both languages. Renames _reject_floats to _reject_unsafe_numbers (private; not exported) and extends it to raise ValueError for any int whose magnitude exceeds 2^53 - 1. bool subclass of int still allowed; container walking (dict, list, tuple, set, frozenset) preserved. Tests: 7 new cases under TestUnsafeNumberRejection (max_safe boundary accept, min_safe boundary accept, 2^53 reject, 2^60 reject, -(2^53) reject, nested 2^60 reject, bool accept). Existing float rejection tests kept intact under the broader class name. Cross-lang fixture: tests/fixtures/cross-lang/{py,node}-int-boundary.json exercise max_safe_int / min_safe_int / small_int / neg_small_int / zero. Both fixtures verify in both languages. node-int-boundary.json was generated by the node-commerce companion script and copied here. scripts/generate_int_boundary_fixture.py: one-shot regenerator. Tests: 809 pass + 3 skipped, 95.22% coverage. ruff + ty clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2fed413 commit ea80759

7 files changed

Lines changed: 229 additions & 13 deletions

File tree

README.md

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

233233
`verify_ucp_profile` enforces the JWS protected header `typ='ucp-profile+jws'`, restricts `alg` to `EdDSA`/`ES256`, requires a `kid`, rejects duplicate kids in the JWKS, and compares the canonical body bytes against the JWS payload to catch swap-after-sign tampering. Failures raise `UCPVerificationError` (a `ValueError` subclass) with a discriminated `code` attribute (`no_signature`/`missing_kid`/`kid_not_found`/`duplicate_kid`/`unsupported_alg`/`wrong_typ`/`signature_invalid`/`body_mismatch`/`malformed_jws`/`malformed_jwks`/`unusable_key`/`unrecognized_critical_header`).
234234

235-
`sign_ucp_profile` rejects profiles containing `float` values: cross-language float canonicalization is not stable, so use decimal strings (e.g. `"9.99"`) for any monetary or fractional fields you put in `extras`.
235+
`sign_ucp_profile` rejects profiles containing `float` values and `int` values whose magnitude exceeds `Number.MAX_SAFE_INTEGER` (2^53 - 1): cross-language float canonicalization is not stable, and Python's arbitrary-width ints lose precision when JS verifiers reparse the canonical body. Use decimal strings (e.g. `"9.99"`) for monetary or fractional fields and for any integer that may exceed the safe range.
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

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
_ALLOWED_ALGS = ("EdDSA", "ES256")
4242
_UCP_TYP = "ucp-profile+jws"
4343

44+
_MAX_SAFE_INT = 2**53 - 1
45+
4446

4547
@contextlib.contextmanager
4648
def _suppress_joserfc_eddsa_warning() -> Iterator[None]:
@@ -159,14 +161,22 @@ def generate_ucp_signing_key(*, kid: str, alg: Literal["EdDSA", "ES256"] = "EdDS
159161
return GeneratedUCPKey(private_key=priv, public_jwk=public_jwk)
160162

161163

162-
def _reject_floats(value: Any) -> None:
163-
"""Walk ``value`` and raise if any non-integer ``float`` is encountered.
164+
def _reject_unsafe_numbers(value: Any) -> None:
165+
"""Walk ``value`` and raise on any number that won't survive cross-language parity.
166+
167+
Two failure modes are rejected:
168+
169+
* Non-integer ``float`` values. Cross-language float canonicalization (RFC 8785
170+
§3.2.2.3) diverges between Python's ``json.dumps`` and Node's ``JSON.stringify``
171+
(e.g. ``1.0`` vs ``1``, ``1e-7`` vs ``1e-07``). Use decimal strings (``"9.99"``)
172+
for monetary or fractional fields.
173+
* ``int`` values whose magnitude exceeds ``Number.MAX_SAFE_INTEGER`` (2^53 - 1).
174+
Python ints are arbitrary-width, but JS verifiers parse the canonical body via
175+
``JSON.parse`` which silently loses precision past 2^53. Use a decimal string
176+
for any integer that may exceed the safe range.
164177
165-
Cross-language float canonicalization (RFC 8785 §3.2.2.3) diverges between
166-
Python's ``json.dumps`` and Node's ``JSON.stringify`` (e.g. ``1.0`` vs ``1``,
167-
``1e-7`` vs ``1e-07``). Catching the drift at sign-time prevents
168-
silent verifier-side failures in production. Use decimal strings (``"9.99"``)
169-
for monetary or fractional fields.
178+
Catching the drift at sign-time prevents silent verifier-side failures in
179+
production.
170180
"""
171181
if isinstance(value, bool):
172182
return # bool subclasses int; allow.
@@ -177,12 +187,19 @@ def _reject_floats(value: Any) -> None:
177187
"to preserve cross-language byte-parity."
178188
)
179189
raise ValueError(msg)
190+
if isinstance(value, int) and abs(value) > _MAX_SAFE_INT:
191+
msg = (
192+
f"UCP profile canonicalization rejects integer {value} that exceeds "
193+
"Number.MAX_SAFE_INTEGER (2^53 - 1). JS verifiers cannot losslessly "
194+
"parse this; use a decimal string to preserve cross-language byte-parity."
195+
)
196+
raise ValueError(msg)
180197
if isinstance(value, dict):
181198
for v in value.values():
182-
_reject_floats(v)
199+
_reject_unsafe_numbers(v)
183200
elif isinstance(value, list | tuple | set | frozenset):
184201
for v in value:
185-
_reject_floats(v)
202+
_reject_unsafe_numbers(v)
186203

187204

188205
def _canonicalize_profile(profile: dict[str, Any]) -> bytes:
@@ -192,13 +209,14 @@ def _canonicalize_profile(profile: dict[str, Any]) -> bytes:
192209
nesting level, returns UTF-8 JSON bytes. Cross-language byte-identical with the
193210
Node ``stableStringify`` output.
194211
195-
Throws ``ValueError`` on float input — see :func:`_reject_floats`.
212+
Throws ``ValueError`` on float input or oversized int (see
213+
:func:`_reject_unsafe_numbers`).
196214
197215
UCP §6.2: "the JSON-serialized profile body, with ``signature`` removed and keys
198216
ordered lexicographically at every nesting level."
199217
"""
200218
stripped = {k: v for k, v in profile.items() if k != "signature"}
201-
_reject_floats(stripped)
219+
_reject_unsafe_numbers(stripped)
202220
# ``ensure_ascii=False`` so non-ASCII characters travel as UTF-8 (matches Node's
203221
# JSON.stringify default). ``sort_keys=True`` sorts keys at every level. Compact
204222
# separators avoid whitespace drift.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""One-shot generator for the int-boundary cross-lang fixture (Python side).
2+
3+
Writes ``tests/fixtures/cross-lang/py-int-boundary.json``. The fixture exercises
4+
the safe-integer boundary that BOTH languages must round-trip identically:
5+
``Number.MAX_SAFE_INTEGER`` (2**53 - 1), its negative, zero, and small ints.
6+
Lossy values (>2**53) are NOT in the fixture (they're rejected at sign time);
7+
they're unit-tested in each language's signing path.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
from pathlib import Path
14+
15+
from agentscore_commerce.identity.ucp_jwks import (
16+
build_jwks_response,
17+
generate_ucp_signing_key,
18+
sign_ucp_profile,
19+
)
20+
21+
OUT = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "cross-lang" / "py-int-boundary.json"
22+
23+
KID = "py-int-boundary-EdDSA"
24+
25+
26+
def main() -> None:
27+
key = generate_ucp_signing_key(kid=KID)
28+
29+
profile = {
30+
"version": "2026-04-17",
31+
"spec": "https://ucp.dev/",
32+
"name": "Int Boundary Merchant",
33+
"services": [{"type": "rest", "url": "https://i.example.com"}],
34+
"capabilities": [],
35+
"payment_handlers": [],
36+
"signing_keys": [key.public_jwk],
37+
"extras": {
38+
"max_safe_int": 9007199254740991,
39+
"min_safe_int": -9007199254740991,
40+
"small_int": 42,
41+
"neg_small_int": -42,
42+
"zero": 0,
43+
},
44+
}
45+
46+
signed = sign_ucp_profile(profile, signing_key=key.private_key, kid=KID)
47+
48+
fixture = {
49+
"profile": signed,
50+
"jwks": build_jwks_response([key.public_jwk]),
51+
"alg": "EdDSA",
52+
"kid": KID,
53+
"generator": "python",
54+
}
55+
56+
OUT.write_text(json.dumps(fixture, indent=2) + "\n")
57+
print(f"wrote {OUT}")
58+
59+
60+
if __name__ == "__main__":
61+
main()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"profile": {
3+
"version": "2026-04-17",
4+
"spec": "https://ucp.dev/",
5+
"services": [
6+
{
7+
"type": "rest",
8+
"url": "https://i.example.com"
9+
}
10+
],
11+
"capabilities": [],
12+
"payment_handlers": [],
13+
"signing_keys": [
14+
{
15+
"kid": "node-int-boundary-EdDSA",
16+
"alg": "EdDSA",
17+
"use": "sig",
18+
"crv": "Ed25519",
19+
"kty": "OKP",
20+
"x": "uCH2zVsMZjpjmCGrrBSSmvWMftXFFCYDAUC5YG54XKw"
21+
}
22+
],
23+
"name": "Int Boundary Merchant",
24+
"max_safe_int": 9007199254740991,
25+
"min_safe_int": -9007199254740991,
26+
"small_int": 42,
27+
"neg_small_int": -42,
28+
"zero": 0,
29+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtaW50LWJvdW5kYXJ5LUVkRFNBIiwidHlwIjoidWNwLXByb2ZpbGUrandzIn0.eyJjYXBhYmlsaXRpZXMiOltdLCJtYXhfc2FmZV9pbnQiOjkwMDcxOTkyNTQ3NDA5OTEsIm1pbl9zYWZlX2ludCI6LTkwMDcxOTkyNTQ3NDA5OTEsIm5hbWUiOiJJbnQgQm91bmRhcnkgTWVyY2hhbnQiLCJuZWdfc21hbGxfaW50IjotNDIsInBheW1lbnRfaGFuZGxlcnMiOltdLCJzZXJ2aWNlcyI6W3sidHlwZSI6InJlc3QiLCJ1cmwiOiJodHRwczovL2kuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoibm9kZS1pbnQtYm91bmRhcnktRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoidUNIMnpWc01aanBqbUNHcnJCU1NtdldNZnRYRkZDWURBVUM1WUc1NFhLdyJ9XSwic21hbGxfaW50Ijo0Miwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyIsInplcm8iOjB9.MABQW9Af3K1ThGkncreJJk-Pv2JdRssGkhO0-UHcZpQmnlriPCJJskL91sgaANfBfNMFRvq6v0xqWeAiMWPqDg"
30+
},
31+
"jwks": {
32+
"keys": [
33+
{
34+
"kid": "node-int-boundary-EdDSA",
35+
"alg": "EdDSA",
36+
"use": "sig",
37+
"crv": "Ed25519",
38+
"kty": "OKP",
39+
"x": "uCH2zVsMZjpjmCGrrBSSmvWMftXFFCYDAUC5YG54XKw"
40+
}
41+
]
42+
},
43+
"alg": "EdDSA",
44+
"kid": "node-int-boundary-EdDSA",
45+
"generator": "node"
46+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"profile": {
3+
"version": "2026-04-17",
4+
"spec": "https://ucp.dev/",
5+
"name": "Int Boundary Merchant",
6+
"services": [
7+
{
8+
"type": "rest",
9+
"url": "https://i.example.com"
10+
}
11+
],
12+
"capabilities": [],
13+
"payment_handlers": [],
14+
"signing_keys": [
15+
{
16+
"crv": "Ed25519",
17+
"x": "orncEOVmokkWyFRnJFYk1TeRC9nrMQG1Ip9kloaOd98",
18+
"kid": "py-int-boundary-EdDSA",
19+
"alg": "EdDSA",
20+
"use": "sig",
21+
"kty": "OKP"
22+
}
23+
],
24+
"extras": {
25+
"max_safe_int": 9007199254740991,
26+
"min_safe_int": -9007199254740991,
27+
"small_int": 42,
28+
"neg_small_int": -42,
29+
"zero": 0
30+
},
31+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWludC1ib3VuZGFyeS1FZERTQSIsInR5cCI6InVjcC1wcm9maWxlK2p3cyJ9.eyJjYXBhYmlsaXRpZXMiOltdLCJleHRyYXMiOnsibWF4X3NhZmVfaW50Ijo5MDA3MTk5MjU0NzQwOTkxLCJtaW5fc2FmZV9pbnQiOi05MDA3MTk5MjU0NzQwOTkxLCJuZWdfc21hbGxfaW50IjotNDIsInNtYWxsX2ludCI6NDIsInplcm8iOjB9LCJuYW1lIjoiSW50IEJvdW5kYXJ5IE1lcmNoYW50IiwicGF5bWVudF9oYW5kbGVycyI6W10sInNlcnZpY2VzIjpbeyJ0eXBlIjoicmVzdCIsInVybCI6Imh0dHBzOi8vaS5leGFtcGxlLmNvbSJ9XSwic2lnbmluZ19rZXlzIjpbeyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJweS1pbnQtYm91bmRhcnktRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4Ijoib3JuY0VPVm1va2tXeUZSbkpGWWsxVGVSQzluck1RRzFJcDlrbG9hT2Q5OCJ9XSwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9.p4tNJUnyRRHUtEBN3_y4DtuKk4CLBQnMfmGHz76wYYaxiAYa0oN251EC4PrkAHrZ6OlgKagTS027yisUf3qeDA"
32+
},
33+
"jwks": {
34+
"keys": [
35+
{
36+
"crv": "Ed25519",
37+
"x": "orncEOVmokkWyFRnJFYk1TeRC9nrMQG1Ip9kloaOd98",
38+
"kid": "py-int-boundary-EdDSA",
39+
"alg": "EdDSA",
40+
"use": "sig",
41+
"kty": "OKP"
42+
}
43+
]
44+
},
45+
"alg": "EdDSA",
46+
"kid": "py-int-boundary-EdDSA",
47+
"generator": "python"
48+
}

tests/test_ucp_cross_lang.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ def test_corpus_covers_canonical_scenarios() -> None:
4545
"unicode",
4646
"multikey",
4747
"emoji-keys",
48+
"int-boundary",
4849
):
4950
assert f"{lang}-{scenario}.json" in names, f"missing fixture {lang}-{scenario}.json"

tests/test_ucp_jwks.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ def test_es256_signing_is_non_deterministic_but_both_verify(self) -> None:
282282
assert verify_ucp_profile(b, build_jwks_response([signer.public_jwk])) is True
283283

284284

285-
class TestFloatRejection:
285+
class TestUnsafeNumberRejection:
286286
def test_rejects_float_in_profile(self) -> None:
287287
signer = generate_ucp_signing_key(kid="k")
288288
profile = {**_base_profile([signer.public_jwk]), "extras": {"rate": 0.0125}}
@@ -325,6 +325,48 @@ def test_rejects_float_in_frozenset(self) -> None:
325325
with pytest.raises(ValueError, match="rejects float"):
326326
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
327327

328+
def test_accepts_max_safe_int_boundary(self) -> None:
329+
signer = generate_ucp_signing_key(kid="k")
330+
profile = {**_base_profile([signer.public_jwk]), "extras": {"big": 2**53 - 1}}
331+
signed = sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
332+
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
333+
334+
def test_accepts_min_safe_int_boundary(self) -> None:
335+
signer = generate_ucp_signing_key(kid="k")
336+
profile = {**_base_profile([signer.public_jwk]), "extras": {"big": -(2**53 - 1)}}
337+
signed = sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
338+
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
339+
340+
def test_rejects_int_above_max_safe_boundary(self) -> None:
341+
signer = generate_ucp_signing_key(kid="k")
342+
profile = {**_base_profile([signer.public_jwk]), "extras": {"big": 2**53}}
343+
with pytest.raises(ValueError, match="MAX_SAFE_INTEGER"):
344+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
345+
346+
def test_rejects_int_well_above_max_safe(self) -> None:
347+
signer = generate_ucp_signing_key(kid="k")
348+
profile = {**_base_profile([signer.public_jwk]), "extras": {"big": 2**60}}
349+
with pytest.raises(ValueError, match="MAX_SAFE_INTEGER"):
350+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
351+
352+
def test_rejects_int_below_min_safe(self) -> None:
353+
signer = generate_ucp_signing_key(kid="k")
354+
profile = {**_base_profile([signer.public_jwk]), "extras": {"neg": -(2**53)}}
355+
with pytest.raises(ValueError, match="MAX_SAFE_INTEGER"):
356+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
357+
358+
def test_rejects_oversized_int_in_nested_list(self) -> None:
359+
signer = generate_ucp_signing_key(kid="k")
360+
profile = {**_base_profile([signer.public_jwk]), "extras": {"a": [{"b": 2**60}]}}
361+
with pytest.raises(ValueError, match="MAX_SAFE_INTEGER"):
362+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
363+
364+
def test_accepts_bool_values(self) -> None:
365+
signer = generate_ucp_signing_key(kid="k")
366+
profile = {**_base_profile([signer.public_jwk]), "extras": {"flag": True, "other": False}}
367+
signed = sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
368+
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
369+
328370

329371
class TestUCPSigningKeyFromJWK:
330372
def test_round_trip_eddsa(self) -> None:

0 commit comments

Comments
 (0)