Skip to content

Signing/verification canonicalization drops REQUIRED fields at their default value, breaks cross-SDK Agent Card signature verification #1278

Description

@aeoess

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:

  1. 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.
  2. _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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

component: coreIssues related to base data models, auth, gRPC interfaces, observability, and fundamental utilities.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions