Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/a2a/utils/_jcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def canonicalize(obj: Any) -> str:
than `MAX_DEPTH`.
"""
out: list[str] = []
_write(obj, out, 0)
_write(obj, out, 1)
canonical = ''.join(out)
try:
# RFC 8785 canonical output is UTF-8. Round-tripping here rejects
Expand All @@ -96,8 +96,13 @@ def canonicalize(obj: Any) -> str:


def _write(obj: Any, out: list[str], depth: int) -> None:
"""Appends the canonical form of `obj` to `out`."""
if depth > MAX_DEPTH:
"""Appends the canonical form of `obj` to `out`.

`depth` is the number of open containers on the path to `obj`, the
outermost at 1. Only containers are recursed into, so only containers
carry a depth worth checking; a scalar's depth is never read.
"""
if isinstance(obj, (list, tuple, dict)) and depth > MAX_DEPTH:
raise CanonicalizationError(
f'nesting exceeds the maximum depth of {MAX_DEPTH}'
)
Expand Down
7 changes: 4 additions & 3 deletions src/a2a/utils/signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,16 @@ def signature_verifier(
return signature_verifier


def _clean_empty(d: Any, depth: int = 0) -> Any:
def _clean_empty(d: Any, depth: int = 1) -> Any:
"""Recursively remove empty strings, lists and dicts from a dictionary.

Depth is bounded for the same reason canonicalization is: nesting reaches
this function from `AgentExtension.params`, and without the bound a deeply
nested card exhausts the interpreter stack here, before the canonicalizer
ever gets the chance to reject it.
ever gets the chance to reject it. `depth` counts open containers on the
path to `d`, the outermost at 1, and matches `_jcs.MAX_DEPTH`'s rule.
"""
if depth > MAX_DEPTH:
if isinstance(d, (dict, list)) and depth > MAX_DEPTH:
raise CanonicalizationError(
f'nesting exceeds the maximum depth of {MAX_DEPTH}'
)
Expand Down
227 changes: 227 additions & 0 deletions tests/utils/jcs_depth_vectors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
{
"corpus": "jcs_depth_v1",
"suite": "a2a-agent-card-signature-conformance",
"layer": "nesting-bound",
"specRef": "https://www.rfc-editor.org/rfc/rfc8785 (no nesting limit stated) and https://a2aproject.org/specification/#841-canonicalization-requirements",
"scope": "The input a canonicalizer must refuse. RFC 8785 pins the bytes a canonicalizer must produce and states no bound on how deeply an input may nest, so two implementations that agree on every byte-level vector can still disagree on whether a deep artifact canonicalizes at all. Canonicalization runs before signature verification, so the walk is reachable without a key.",
"max_depth": 128,
"depth_counting_rule": "Depth counts OPEN CONTAINERS: the outermost brace or bracket is depth 1, and every object or array opened inside it adds one, an empty container included. A counter that charges a level per parsed child instead never charges an empty container and lands one level off - which is what the empty-container vectors separate.",
"reference_impl": "Python rfc8785 0.1.4 (Trail of Bits)",
"attribution": {
"corpus": "jcs_depth_v1",
"author": "Sankalp Gilda",
"license": "Apache-2.0",
"upstream": "https://github.com/a2aproject/A2A/pull/2246 (proposals/content-integrity-profile/vectors/jcs_depth_v1)",
"source_corpus": {
"name": "agent-evidence-vectors",
"suite": "adversarial-execution-evidence-conformance",
"repository": "https://github.com/astrogilda/agent-evidence-vectors"
},
"note": "Retained as published: every expected byte string was produced by canonicalising the materialised preimage with the reference implementation above, and all preimage digests are checked before any expectation is used."
},
"preimage_rule_form": {
"note": "Preimages are given as nesting rules rather than literal JSON so this file stays four levels deep and can be read by a parser that enforces the bound it describes. Materialise as text: for i from 0 to count-1 emit the opener for containers[i % len(containers)] ('{\"a\":' for object, '[' for array), then emit leaf, then emit the matching closers innermost-first.",
"openers": {
"object": "{\"a\":",
"array": "["
},
"closers": {
"object": "}",
"array": "]"
}
},
"vectors": [
{
"vector_id": "jcs-depth-001-object-at-bound",
"description": "128 nested objects, the deepest input the bound admits. A canonicaliser that applies the bound one level early refuses this and is off by one.",
"outcome": "accept",
"depth": 128,
"preimage_rule": {
"form": "nest",
"containers": [
"object"
],
"count": 128,
"leaf": "1"
},
"preimage_bytes": 769,
"preimage_sha256": "a4908c65856c2fb1e94d6b2b55620177bd082f54d43252b9e0648f9ccd53e3fe",
"expected_jcs_bytes_b64": "eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOjF9fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fQ==",
"expected_sha256": "a4908c65856c2fb1e94d6b2b55620177bd082f54d43252b9e0648f9ccd53e3fe",
"trace": {
"corpus": "agent-evidence-vectors"
}
},
{
"vector_id": "jcs-depth-002-array-at-bound",
"description": "128 nested arrays at the bound. Separated from the object case because a depth counter placed only in the object branch never charges an array.",
"outcome": "accept",
"depth": 128,
"preimage_rule": {
"form": "nest",
"containers": [
"array"
],
"count": 128,
"leaf": "1"
},
"preimage_bytes": 257,
"preimage_sha256": "68da6c21da4d39e99f241a56379e98c2053372e77cd74f72400af6d3a37261c3",
"expected_jcs_bytes_b64": "W1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1sxXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV0=",
"expected_sha256": "68da6c21da4d39e99f241a56379e98c2053372e77cd74f72400af6d3a37261c3",
"trace": {
"corpus": "agent-evidence-vectors"
}
},
{
"vector_id": "jcs-depth-003-alternating-at-bound",
"description": "128 containers alternating object, array, object, array at the bound. Catches a counter that tracks one container kind and resets on the other.",
"outcome": "accept",
"depth": 128,
"preimage_rule": {
"form": "nest",
"containers": [
"object",
"array"
],
"count": 128,
"leaf": "1"
},
"preimage_bytes": 513,
"preimage_sha256": "aa4e4f042129f600f2352f2ecd58559409175fd83cf4918c382afc3d33942268",
"expected_jcs_bytes_b64": "eyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbMV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19",
"expected_sha256": "aa4e4f042129f600f2352f2ecd58559409175fd83cf4918c382afc3d33942268",
"trace": {
"corpus": "agent-evidence-vectors"
}
},
{
"vector_id": "jcs-depth-004-empty-object-leaf-at-bound",
"description": "127 wrapping objects around an empty object. The empty container is itself the 128th open container, so this sits exactly at the bound and is accepted.",
"outcome": "accept",
"depth": 128,
"preimage_rule": {
"form": "nest",
"containers": [
"object"
],
"count": 127,
"leaf": "{}"
},
"preimage_bytes": 764,
"preimage_sha256": "95abadd19f4a27dd41c2e3b46baca7320d039f4e4686fcdaebb0a7d7146bb16e",
"expected_jcs_bytes_b64": "eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX0=",
"expected_sha256": "95abadd19f4a27dd41c2e3b46baca7320d039f4e4686fcdaebb0a7d7146bb16e",
"trace": {
"corpus": "agent-evidence-vectors"
}
},
{
"vector_id": "jcs-depth-101-object-one-past-bound",
"description": "129 nested objects, one level past the bound. Differs from jcs-depth-001-object-at-bound by exactly one wrapping object.",
"outcome": "reject",
"depth": 129,
"preimage_rule": {
"form": "nest",
"containers": [
"object"
],
"count": 129,
"leaf": "1"
},
"preimage_bytes": 775,
"preimage_sha256": "eeb23e8c9c090d0303063348ae7ca4a5914992972fc20e295e8e386802e2a95a",
"trace": {
"corpus": "agent-evidence-vectors",
"sibling_vector_id": "v08251931ec038e91"
}
},
{
"vector_id": "jcs-depth-102-array-one-past-bound",
"description": "129 nested arrays, one level past the bound.",
"outcome": "reject",
"depth": 129,
"preimage_rule": {
"form": "nest",
"containers": [
"array"
],
"count": 129,
"leaf": "1"
},
"preimage_bytes": 259,
"preimage_sha256": "84aaf90be3265f8e148bdcc72153a59156d358f74e3dedd2d6a5dd566f5944f5",
"trace": {
"corpus": "agent-evidence-vectors"
}
},
{
"vector_id": "jcs-depth-103-empty-object-leaf-one-past-bound",
"description": "128 wrapping objects around an empty object: open-container depth 129, one past the bound. This is the case a scalar leaf cannot discriminate. A canonicaliser that charges a level per parsed child rather than per opened container never charges the empty object, reads this as depth 128 and accepts it while refusing jcs-depth-101-object-one-past-bound.",
"outcome": "reject",
"depth": 129,
"preimage_rule": {
"form": "nest",
"containers": [
"object"
],
"count": 128,
"leaf": "{}"
},
"preimage_bytes": 770,
"preimage_sha256": "47ec83edd0d185f58e09a843867e31af9088c4da554f55c730c52f547161a8b1",
"trace": {
"corpus": "agent-evidence-vectors",
"sibling_vector_id": "v83f4b7fe6068ef86"
}
},
{
"vector_id": "jcs-depth-104-deep-input-refused-not-crashed",
"description": "500 nested arrays: well past the bound, but shallow enough that the JSON decoder accepts the input (CPython's decoder recurses to about 1000 levels), so the refusal has to come from the depth bound and reach the caller as a catchable rejection rather than as a decoder failure or a stack crash. A payload of ten million levels does not test that: json.loads refuses it before canonicalization is entered at all, so the vector would pin the decoder's own recursion limit instead of the bound.",
"outcome": "reject",
"depth": 500,
"preimage_rule": {
"form": "nest",
"containers": [
"array"
],
"count": 500,
"leaf": "1"
},
"preimage_bytes": 1001,
"preimage_sha256": "a655facd9262ddb5ddf32b1f4f8d937fb2ded910b13fe7f12c44abb2783756eb",
"trace": {
"corpus": "agent-evidence-vectors"
}
}
],
"pair_invariants": [
{
"name": "object_bound_is_exact",
"a": "jcs-depth-001-object-at-bound",
"b": "jcs-depth-101-object-one-past-bound",
"relation": "accept_then_reject",
"why": "The two inputs differ by one wrapping object. An implementation that accepts both has no bound; one that refuses both has the bound off by one. Only the pair locates it."
},
{
"name": "array_bound_is_exact",
"a": "jcs-depth-002-array-at-bound",
"b": "jcs-depth-102-array-one-past-bound",
"relation": "accept_then_reject",
"why": "The same boundary in the array branch, which a counter placed only in the object branch never reaches."
},
{
"name": "empty_container_charges_a_level",
"a": "jcs-depth-004-empty-object-leaf-at-bound",
"b": "jcs-depth-103-empty-object-leaf-one-past-bound",
"relation": "accept_then_reject",
"why": "A per-child counter never charges an empty container, so it reads the second input as depth 128 and accepts it. Both scalar-leaf vectors pass under that counter, which is why this pair exists."
},
{
"name": "refusal_is_not_a_crash",
"vector": "jcs-depth-104-deep-input-refused-not-crashed",
"relation": "reject_without_stack_exhaustion",
"why": "The vector is passed only if the implementation returns a rejection to its caller. A process that dies on this input has not rejected it, and a crash on an unauthenticated artifact is the outcome the bound exists to prevent."
}
]
}
103 changes: 103 additions & 0 deletions tests/utils/test_jcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
own output.
"""

import base64
import hashlib
import json
import math
import random
Expand Down Expand Up @@ -368,6 +370,107 @@ def test_deep_arrays_are_bounded():
canonicalize(value)


# --- Nesting bound: the jcs_depth_v1 corpus -------------------------------

# `jcs_depth_vectors.json` is the `jcs_depth_v1` corpus. It pins the input a
# canonicalizer must REFUSE rather than the bytes it must produce, because RFC
# 8785 states no nesting limit and canonicalization runs before signature
# verification. Depth counts open containers, the outermost at depth 1, and an
# empty container is its own level.
_DEPTH_VECTORS = json.loads(
(Path(__file__).parent / 'jcs_depth_vectors.json').read_text(
encoding='utf-8'
)
)
_DEPTH_ACCEPT = [
v for v in _DEPTH_VECTORS['vectors'] if v['outcome'] == 'accept'
]
_DEPTH_REJECT = [
v for v in _DEPTH_VECTORS['vectors'] if v['outcome'] == 'reject'
]

_DEPTH_OPENERS = {'object': '{"a":', 'array': '['}
_DEPTH_CLOSERS = {'object': '}', 'array': ']'}


def _materialise_depth(rule: dict[str, Any]) -> str:
"""Materialises a preimage rule into the JSON text it describes."""
containers = rule['containers']
opened = ''.join(
_DEPTH_OPENERS[containers[i % len(containers)]]
for i in range(rule['count'])
)
closed = ''.join(
_DEPTH_CLOSERS[containers[i % len(containers)]]
for i in range(rule['count'] - 1, -1, -1)
)
return opened + rule['leaf'] + closed


def _empty_leaf_nest(depth: int) -> dict[str, Any]:
"""Wraps `{}` in `depth` objects: the shape an empty container sits in."""
value: Any = {}
for _ in range(depth):
value = {'a': value}
return value


@pytest.mark.parametrize(
'vector', _DEPTH_VECTORS['vectors'], ids=lambda v: v['vector_id']
)
def test_depth_vector_preimage_is_the_pinned_one(vector):
"""The input is checked before the output: a vector built wrong proves nothing."""
encoded = _materialise_depth(vector['preimage_rule']).encode('utf-8')
assert len(encoded) == vector['preimage_bytes']
assert hashlib.sha256(encoded).hexdigest() == vector['preimage_sha256']


@pytest.mark.parametrize('vector', _DEPTH_ACCEPT, ids=lambda v: v['vector_id'])
def test_depth_at_the_bound_is_canonicalised(vector):
"""The deepest input the limit admits, the empty-container leaf included."""
canonical = canonicalize(
json.loads(_materialise_depth(vector['preimage_rule']))
)
encoded = canonical.encode('utf-8')
assert encoded == base64.b64decode(vector['expected_jcs_bytes_b64'])
assert hashlib.sha256(encoded).hexdigest() == vector['expected_sha256']


@pytest.mark.parametrize('vector', _DEPTH_REJECT, ids=lambda v: v['vector_id'])
def test_depth_past_the_bound_is_refused(vector):
"""One container past the limit is refused whatever the innermost value is.

The refusal is `CanonicalizationError` for every reject vector: the deepest
payload here (jcs-depth-104) is far past the bound but still shallow enough
for the JSON decoder to accept it, so the bound - not a decoder limit and
not a stack exhaustion - is what the caller sees.
"""
with pytest.raises(CanonicalizationError):
canonicalize(json.loads(_materialise_depth(vector['preimage_rule'])))


def test_an_empty_container_leaf_is_its_own_level():
"""The case a per-child counter misses.

A counter that charges a level on recursion into a child never charges an
empty container, so it accepts one container too many here while still
refusing the same depth wrapped around a scalar.
"""
at_bound = _empty_leaf_nest(MAX_DEPTH - 1)
assert canonicalize(at_bound) == (
'{"a":' * (MAX_DEPTH - 1) + '{}' + '}' * (MAX_DEPTH - 1)
)
with pytest.raises(CanonicalizationError):
canonicalize(_empty_leaf_nest(MAX_DEPTH))


def test_clean_empty_counts_containers_too():
"""`_clean_empty` runs first, so it has to hold the same bound."""
signing._clean_empty(_empty_leaf_nest(MAX_DEPTH - 1))
with pytest.raises(CanonicalizationError):
signing._clean_empty(_empty_leaf_nest(MAX_DEPTH))


# --- Types with no canonical form ---


Expand Down
Loading