What happened
_canonicalize_agent_card (used by both create_agent_card_signer and create_signature_verifier in src/a2a/utils/signing.py) removes empty strings, empty lists, and empty dicts from the Agent Card before canonicalizing, with no exception for fields the spec marks REQUIRED. Section 8.4.1 of the A2A specification says the opposite: REQUIRED fields must stay in the canonical payload even when they hold their default value.
The result is that a card signed by an SDK that keeps a REQUIRED field at its default value in the signed payload (for example description: "" or skills: []) fails verification in a2a-python, because a2a-python recomputes a different canonical payload for the same card.
Where it happens
At main, commit 0d5473ca4fa6d40034a6a7c8d65bce5cd85d8167:
src/a2a/utils/signing.py, _canonicalize_agent_card (lines 198-208):
def _canonicalize_agent_card(agent_card: AgentCard) -> str:
"""Canonicalizes the Agent Card JSON according to RFC 8785 (JCS)."""
card_dict = MessageToDict(
agent_card,
)
# Remove signatures field if present
card_dict.pop('signatures', None)
# Recursively remove empty values
cleaned_dict = _clean_empty(card_dict)
return canonicalize(cleaned_dict)
Two things contribute:
MessageToDict(agent_card) is called without always_print_fields_with_no_presence=True. Proto3 fields that have no explicit presence tracking (this includes the REQUIRED fields description, skills, and AgentSkill.tags, none of which carry the optional keyword) are omitted from the dict entirely once they hold the default value, before _clean_empty ever runs.
_clean_empty (lines 167-195) then unconditionally strips any remaining empty string, list, or dict:
def _clean_empty(d: Any, depth: int = 0) -> Any:
...
if isinstance(d, dict):
cleaned_dict = {
k: cleaned_v
for k, v in d.items()
if (cleaned_v := _clean_empty(v, depth + 1)) is not None
}
return cleaned_dict or None
if isinstance(d, list):
cleaned_list = [
cleaned_v
for v in d
if (cleaned_v := _clean_empty(v, depth + 1)) is not None
]
return cleaned_list or None
if isinstance(d, str) and not d:
return None
return d
Neither step distinguishes a REQUIRED field at its default value from an optional or repeated field that happens to be empty.
Permalink:
|
def _clean_empty(d: Any, depth: int = 0) -> 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. |
|
""" |
|
if depth > MAX_DEPTH: |
|
raise CanonicalizationError( |
|
f'nesting exceeds the maximum depth of {MAX_DEPTH}' |
|
) |
|
if isinstance(d, dict): |
|
cleaned_dict = { |
|
k: cleaned_v |
|
for k, v in d.items() |
|
if (cleaned_v := _clean_empty(v, depth + 1)) is not None |
|
} |
|
return cleaned_dict or None |
|
if isinstance(d, list): |
|
cleaned_list = [ |
|
cleaned_v |
|
for v in d |
|
if (cleaned_v := _clean_empty(v, depth + 1)) is not None |
|
] |
|
return cleaned_list or None |
|
if isinstance(d, str) and not d: |
|
return None |
|
return d |
|
|
|
|
|
def _canonicalize_agent_card(agent_card: AgentCard) -> str: |
|
"""Canonicalizes the Agent Card JSON according to RFC 8785 (JCS).""" |
|
card_dict = MessageToDict( |
|
agent_card, |
|
) |
|
# Remove signatures field if present |
|
card_dict.pop('signatures', None) |
|
|
|
# Recursively remove empty values |
|
cleaned_dict = _clean_empty(card_dict) |
|
return canonicalize(cleaned_dict) |
Spec requirement
A2A specification, docs/specification.md, section 8.4.1, "Canonicalization Requirements" (checked at commit 43e0c874d3baba68ed84b98678d7f2268438e69f):
- Required fields: Fields marked with
REQUIRED MUST always be present, even if the field value matches the default.
- Default values: Fields with default values MUST be omitted unless the field is marked as
REQUIRED or has the optional keyword.
The section's own worked example signs this fragment:
{
"name": "Example Agent",
"description": "",
"capabilities": {
"streaming": false,
"pushNotifications": false,
"extensions": []
},
"skills": []
}
and gives this as the canonical result:
{"capabilities":{"pushNotifications":false,"streaming":false},"description":"","name":"Example Agent","skills":[]}
description (REQUIRED, empty string) and skills (REQUIRED, empty array) both stay in the canonical payload. extensions (not REQUIRED, empty array) is omitted. Current _canonicalize_agent_card drops description and skills along with extensions, contradicting the first bullet above.
Minimal reproduction
Python only, no other SDK needed:
from a2a.types import AgentCapabilities, AgentCard, AgentInterface
from a2a.utils.signing import _canonicalize_agent_card
card = AgentCard(
name="Example Agent",
description="",
version="1.0.0",
supported_interfaces=[
AgentInterface(
url="https://example.com/a2a/v1",
protocol_binding="JSONRPC",
protocol_version="1.0",
)
],
capabilities=AgentCapabilities(streaming=False, push_notifications=False),
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[],
)
print(_canonicalize_agent_card(card))
Expected per 8.4.1: description and skills are present in the output ("description":"", "skills":[]).
Actual: both are absent from the canonical payload.
Cross-SDK consequence
We ran this against a2a-go PR #441 (bb750f5c7913b967d8dcbe3d24537a8a84dd1a61), which keeps description and skills in the signed wire JSON when they are at their default value (its Go struct tags do not mark those fields omitempty). Five single-field cases, one Ed25519 key shared on both sides:
| Case |
Go signs, Python verifies |
Python signs, Go verifies |
description: "" |
fails, InvalidSignaturesError: No valid signature found |
passes |
skills: [] |
fails, same error |
passes |
capabilities.extensions: [] (not REQUIRED) |
passes |
passes |
skill.tags: [] (REQUIRED on AgentSkill per a2a.proto) |
fails, same error |
passes |
| control, no empty/default values |
passes |
passes |
The a2a-go maintainer reached the same reading on #441: "python behaviour is not spec-compliant here".
Python signs a card and Go verifies it without trouble in every case, because a2a-go's own canonicalizer does not remove already-present fields. The failures only run in the direction where Go emits a spec-compliant payload (REQUIRED field present at default) and Python's canonicalizer removes what the signer actually signed.
Proposed fix direction
Build the canonical JSON according to the field-presence rules in section 8.4.1: preserve REQUIRED fields even at their default value, preserve explicitly set optional fields, and continue omitting non-required default-valued fields. One implementation could use MessageToDict(..., always_print_fields_with_no_presence=True) followed by descriptor-aware filtering based on field_behavior and field presence.
Related
What happened
_canonicalize_agent_card(used by bothcreate_agent_card_signerandcreate_signature_verifierinsrc/a2a/utils/signing.py) removes empty strings, empty lists, and empty dicts from the Agent Card before canonicalizing, with no exception for fields the spec marks REQUIRED. Section 8.4.1 of the A2A specification says the opposite: REQUIRED fields must stay in the canonical payload even when they hold their default value.The result is that a card signed by an SDK that keeps a REQUIRED field at its default value in the signed payload (for example
description: ""orskills: []) fails verification in a2a-python, because a2a-python recomputes a different canonical payload for the same card.Where it happens
At
main, commit0d5473ca4fa6d40034a6a7c8d65bce5cd85d8167:src/a2a/utils/signing.py,_canonicalize_agent_card(lines 198-208):Two things contribute:
MessageToDict(agent_card)is called withoutalways_print_fields_with_no_presence=True. Proto3 fields that have no explicit presence tracking (this includes the REQUIRED fieldsdescription,skills, andAgentSkill.tags, none of which carry theoptionalkeyword) are omitted from the dict entirely once they hold the default value, before_clean_emptyever runs._clean_empty(lines 167-195) then unconditionally strips any remaining empty string, list, or dict:Neither step distinguishes a REQUIRED field at its default value from an optional or repeated field that happens to be empty.
Permalink:
a2a-python/src/a2a/utils/signing.py
Lines 167 to 208 in 0d5473c
Spec requirement
A2A specification,
docs/specification.md, section 8.4.1, "Canonicalization Requirements" (checked at commit43e0c874d3baba68ed84b98678d7f2268438e69f):The section's own worked example signs this fragment:
{ "name": "Example Agent", "description": "", "capabilities": { "streaming": false, "pushNotifications": false, "extensions": [] }, "skills": [] }and gives this as the canonical result:
description(REQUIRED, empty string) andskills(REQUIRED, empty array) both stay in the canonical payload.extensions(not REQUIRED, empty array) is omitted. Current_canonicalize_agent_carddropsdescriptionandskillsalong withextensions, contradicting the first bullet above.Minimal reproduction
Python only, no other SDK needed:
Expected per 8.4.1:
descriptionandskillsare present in the output ("description":"","skills":[]).Actual: both are absent from the canonical payload.
Cross-SDK consequence
We ran this against a2a-go PR #441 (
bb750f5c7913b967d8dcbe3d24537a8a84dd1a61), which keepsdescriptionandskillsin the signed wire JSON when they are at their default value (its Go struct tags do not mark those fieldsomitempty). Five single-field cases, one Ed25519 key shared on both sides:description: ""InvalidSignaturesError: No valid signature foundskills: []capabilities.extensions: [](not REQUIRED)skill.tags: [](REQUIRED onAgentSkillpera2a.proto)The a2a-go maintainer reached the same reading on #441: "python behaviour is not spec-compliant here".
Python signs a card and Go verifies it without trouble in every case, because a2a-go's own canonicalizer does not remove already-present fields. The failures only run in the direction where Go emits a spec-compliant payload (REQUIRED field present at default) and Python's canonicalizer removes what the signer actually signed.
Proposed fix direction
Build the canonical JSON according to the field-presence rules in section 8.4.1: preserve REQUIRED fields even at their default value, preserve explicitly set optional fields, and continue omitting non-required default-valued fields. One implementation could use
MessageToDict(..., always_print_fields_with_no_presence=True)followed by descriptor-aware filtering based onfield_behaviorand field presence.Related
MessageToDict-drops-REQUIRED-empty-fields root behavior) but cover the card-serving and validation paths, not the signing/verification canonicalization function.