fix(embodied-action): use the existing RFC 8785/JCS canonicalizer instead of a non-compliant one - #591
fix(embodied-action): use the existing RFC 8785/JCS canonicalizer instead of a non-compliant one#591rajnisht7 wants to merge 5 commits into
Conversation
Signed-off-by: rajnisht7 <rajnishtiwari9787@gmail.com>
67fde16 to
e3953fc
Compare
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
I think this PR has one scope boundary worth fixing before merge.
So this changes more than byte representation. For example a detached payload containing an otherwise valid JCS optional value such as: {"approval_context":{"risk_score":0.25}}was hashable before this PR and is valid JCS, but now There is a second consequence in the verifier path: I would separate the two questions:
If the intended ruling is instead that embodied-action v0 also adopts the stricter refusal set, I think the profile text needs to state that explicitly and the verifier should catch canonicalization refusal and report it as a verification failure. A verifier-level regression using a float in an optional nested field would make the failure path load-bearing. The |
|
One thing outside this diff's scope worth flagging: the same non ASCII byte divergence problem this PR fixes for embodied action evidence is still live in two other canonicalizers, That path signs and verifies TRACE Claims, which is a more sensitive boundary than embodied action hashing. A signer or verifier that is actually RFC 8785 compliant would produce a different byte string for any non ASCII field, so a legitimate signature could fail to verify, or two payloads that should hash differently under real JCS could still collide under the old escaping scheme. Might be worth a follow up issue so the fix lands everywhere the codebase claims RFC 8785 compliance, not just this one call site. |
qubeena07
left a comment
There was a problem hiding this comment.
This looks like the right direction, using the existing JCS canonicalizer instead of duplicating one, but there is one blocking correctness issue before merge, the same one altrudev flagged above.
canonical_json now raises CatalogApprovalError on floats, integers above 2**53 minus 1, or unpaired surrogates. Neither verify_embodied_action_evidence nor _verify_hash_value catches that. So a detached payload with something like {"approval_context": {"risk_score": 0.25}}, valid JCS and hashable before this change, now crashes verification instead of returning a result with failures populated. That turns a graceful verification failure into an unhandled exception on untrusted external input, which breaks the fail closed pattern used everywhere else in this module.
I would want either the verifier to catch canonicalization failure and record it as a verification failure, or an explicit decision that this profile is intentionally narrower than plain JCS, with the spec doc updated to say so.
Once that is resolved I am fine approving. The note on trace_claim.py and verify.py in my other comment is not blocking, can be a follow up issue.
imran-siddique
left a comment
There was a problem hiding this comment.
The JCS switch is right and it is the ruling from #588: one canonicalizer, approval.py's, and the embodied_action.py serializer retired rather than kept as a second definition of "canonical". The refusals come with it deliberately.
@qubeena07's review was correct and caught the class, and your fix at 9612204 handles the path they named: _verify_hash_value is now wrapped, a refusal is recorded as a failure, and the comment says why the detached payload deserves that treatment. That is the right shape.
One call site is still unwrapped, and it is reachable.
canonical_json_bytes has three callers. Two are inside hash_embodied_action_payload, reached through _verify_hash_value, which your try/except now covers. The third is compute_action_ref, called at line 208 on the same attacker-supplied detached_payload, with nothing around it.
The guard above it does narrow the exposure:
if all(isinstance(detached_payload.get(field), str) for field in _ACTION_REF_FIELDS):
expected_action_ref = compute_action_ref(detached_payload)So @qubeena07's own example, {"risk_score": 0.25}, cannot reach it: a float fails the isinstance check. Big integers are excluded the same way.
A lone surrogate is a str. I ran the real canonicalizer to be sure rather than reasoning about it:
plain ascii -> ok (80 bytes)
non-ascii -> ok (81 bytes)
LONE SURROGATE -> CatalogApprovalError: canonical JSON cannot encode an unpaired surrogate
float -> CatalogApprovalError: canonical JSON does not accept floating point numbers
big int -> CatalogApprovalError: integer is outside the range RFC 8785 serializes exactly
canonical_json builds the text with ensure_ascii=False, which is correct and required for JCS, and then text.encode("utf-8") raises on an unpaired surrogate. So a detached payload carrying "agent_id": "\ud800" passes the isinstance(str) guard and raises out of compute_action_ref, unhandled, on untrusted input. Same failure mode you just fixed, one line lower.
Wrapping line 208 the same way you wrapped 192 closes it. A canonicalization refusal there should record something like "action_ref preimage could not be canonicalized" rather than crash, for the same reason: an attacker choosing the input should not choose between a verification failure and an exception.
Worth a test alongside the ones you added: a payload whose agent_id is a lone surrogate, asserting failures is populated rather than an exception escaping. Your existing float-rejection test covers the other branch.
Nothing else here needs changing. The four tests you added, raw UTF-8 output, UTF-16 key ordering, float rejection and the independent JCS reference hash, are the right set, and checking the ROS 2 fixture still passes was the right instinct given the refusals are new.
Push that and I will merge. @qubeena07, thank you for the review; it was specific enough to act on and you were right that this was the blocking one.
|
One more angle worth checking before merge, same failure class as the two already fixed here: recursion depth. The new canonicalizer recurses in pure Python, the old
Suggest either catching |
qubeena07
left a comment
There was a problem hiding this comment.
Confirmed, both call sites are wrapped now, _verify_hash_value and compute_action_ref, and the lone surrogate case imran caught has a dedicated test alongside the float one. Clearing my request.
Left a non-blocking follow-up comment on recursion depth, separate from this approval.
What
Updated canonical_json_bytes() to use the existing RFC 8785/JCS canonical JSON implementation instead of json.dumps().
Added tests for:
Also verified the existing ROS 2 fixture still works.
Why
The previous serializer was not RFC 8785 compliant because it escaped non-ASCII characters and used Unicode code-point ordering instead of UTF-16 ordering. This could produce different hashes and action_ref values between conformant implementations.
Using the existing tested JCS implementation keeps canonicalization consistent and reproducible.
Note: the shared implementation rejects floats and integers outside the safe 2^53-1 range. Current action refs and fixtures are unaffected, but floats in future detached payloads will now be rejected.
Security impact
Improves hash and action_ref consistency by ensuring RFC 8785/JCS-compliant canonicalization. No other security boundary changes.
Test plan
pytestpassesruff checkpassesmypypassesDCO sign-off
Developer Certificate of Origin (https://developercertificate.org).