Skip to content

fix(signing)!: preserve Agent Card presence for the next major release - #1360

Open
UncleSaurus wants to merge 3 commits into
a2aproject:mainfrom
UncleSaurus:feat/3679-signing-next-major
Open

UncleSaurus wants to merge 3 commits into
a2aproject:mainfrom
UncleSaurus:feat/3679-signing-next-major

Conversation

@UncleSaurus

Copy link
Copy Markdown

Agent Card signing currently drops required defaults and explicitly present optional defaults, and recursively removes valid JSON empties. This draft next-major proposal preserves field presence before the existing RFC 8785 canonicalizer so the public signer and verifier follow the published A2A 1.0.1 presence rules.

This intentionally changes some signed bytes. Following the major-release requirement on #1355, it proposes SDK 2.0.0, independently of the A2A wire-protocol version. It leaves the required-field warning policy unchanged and adds no legacy-signature retry or alternate canonicalization path. Cross-SDK agreement and the release window remain upstream decisions; this does not close #1278 or a2aproject/A2A#2122.

  • Shares the existing required-field annotation logic, preserves explicitly present defaults and arbitrary extension JSON, excludes root signatures, and retains the existing independent interop vectors.
  • Versions the card route's weak ETag at the representation boundary while preserving randomized re-signing revalidation. The migration guide covers co-deployment, old signatures served by an already-upgraded route, cache purge/content-version remedies, omitted required defaults, and key rotation.
  • Proposes a one-release release-as: 2.0.0 override because the current always-bump-patch strategy selects a patch even for a breaking commit. The release owner must remove the override after 2.0.0 and before later releases. No release manifest, tag, or publication is advanced here.

Validation: ./scripts/lint.sh, uv run pytest, and uv run pytest --cov=src --cov-report=term-missing pass on CPython 3.10.19: 2285 passed, 183 skipped, 3 xfailed, 1 xpassed, 93% coverage. Skips are missing Postgres/MySQL DSNs and SQLite row locking. A completed full-worktree Claude Opus 5.5 review approved head d11c05d, independently exercising presence-byte/signature oracles, valid-card controls, cache rollout orderings, protobuf 5.29.5/7.36.2, and release-please 17.6.0's real manifest/strategy selection. It also ran focused checks on Python 3.14.2. No other language SDK, real CDN, or publication was exercised; fresh repository CI remains required.

Related to #1278, #1355, and a2aproject/A2A#2122.

BREAKING CHANGE: Agent Card signing and verification preserve required fields, explicitly present optional defaults and extension JSON values. Affected cards must be re-signed with coordinated verifier rollout. Propose the next SDK major release with an explicit 2.0.0 release boundary. Required-field warning policy is unchanged.

Related to a2aproject#1278 and a2aproject/A2A#2122; their broader discussions remain open.
@github-actions

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/server/routes/agent_card_routes.py 85.00% 85.71% 🟢 +0.71%
src/a2a/utils/proto_utils.py 95.65% 95.69% 🟢 +0.04%
src/a2a/utils/signing.py 95.77% 96.94% 🟢 +1.16%
Total 92.89% 92.92% 🟢 +0.02%

Generated by coverage-comment.yml

@chopmob-cloud

Copy link
Copy Markdown
Contributor

Independent verification — a2a-python PR #1360

Tested head d11c05d (feat/3679-signing-next-major) against a frozen real-world fixture: an ES256-signed Agent Card published by the MCP Verification Gate project (gate_card_20260928.json, 9420 bytes, SHA256 2df33ff1…), cross-signed with the companion JWKS (gate_jwks_20260928.json, SHA256 692fd49d…, kid hs-2026-09). The canonical bytes produced by the JS SDK from this card are the baseline: 6410 bytes, SHA256 c5d5384a….

Results

Check Result
Fixture integrity (card 2df33ff1 / JWKS 692fd49d) PASS
Overshoot absent: /capabilities/extensions/{0,1}/required, /supportedInterfaces/{0,1}/tenant PASS × 4
REQUIRED-field preservation: description="" and skills=[] present in canonical bytes PASS
Control bytes match JS-SDK reference (6410 / c5d5384a) FAIL — 6438 bytes / e28c62eb (+28 bytes)
ES256 signature from JS SDK verifies FAIL (consequence of byte change)
Empty securityRequirements: [{}] collapses to absent FAIL

Finding: _apply_field_presence does not post-process repeated message fields to remove empty elements

The frozen card has one SecurityRequirement entry with no schemes set — i.e. security_requirements: [{}]. The old _clean_empty would recurse into the list, find the entry reduced to {}, strip it, and then strip the now-empty list. _apply_field_presence does not do this.

The relevant branch is:

elif repeated:
    for item, item_dict in zip(value, serialized[key], strict=True):
        _apply_field_presence(item, item_dict)   # mutates item_dict in-place
# nothing removes item_dict if it became {}
# nothing removes the list if all entries became {}

After recursing into the one SecurityRequirement{}, item_dict is an empty dict {}. It stays in the list. The list [{}] stays in the serialized output. Result: canonical bytes grow by 28 bytes and the ES256 signature computed by the JS SDK no longer verifies.

The fix is a post-processing step after the elif repeated: block:

elif repeated:
    for item, item_dict in zip(value, serialized[key], strict=True):
        _apply_field_presence(item, item_dict)
    # collapse entries that reduced to {}
    serialized[key] = [d for d in serialized[key] if d]
    if not serialized[key]:
        del serialized[key]

Is this behaviour intentional?

I raise this because the spec leaves room for ambiguity. §8.4.1 says "explicitly present" optional defaults should be retained. A SecurityRequirement{} with no schemes set is arguably explicitly present (the caller added it). But practically, a single empty security requirement is not a meaningful constraint — it would be indistinguishable from "no security requirements" in any enforcing agent, and the old behaviour (collapse it) matched the JS SDK output.

If the intent is to preserve explicitly set but empty message entries in repeated fields, that would be a new semantic not in the old code — and it would break cross-SDK sig verification with any JS-signed card that carries such an entry. Worth clarifying before merge whether the goal is:

  • A: retain the old collapse semantics for repeated message entries (fix the loop as above), or
  • B: intentionally preserve empty-but-set entries and update the cross-SDK test fixtures accordingly.

What passes

The REQUIRED-field injection is correct. A card with description="" and skills=[] now includes both in canonical bytes; before this PR they were stripped. The overshoot guard (_field_is_required + descriptor-aware cleanup) correctly excludes the four extra leaves that always_print_fields_with_no_presence=True alone would add. That part of the PR is solid.

Signed-off-by: AlgoVoi chopmob@gmail.com

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants