Skip to content

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

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

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

Conversation

@UncleSaurus

@UncleSaurus UncleSaurus commented Oct 20, 2026 •

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.
  • Does not pin the version. The current always-bump-patch strategy selects a patch even for a breaking commit, so, as for 1.3.0 (chore: release 1.3.0 #1389), the release owner sets 2.0.0 with an empty Release-As commit. 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

github-actions Bot commented Oct 20, 2026 •

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.97% 96.00% 🟢 +0.03%
src/a2a/utils/signing.py 96.15% 97.14% 🟢 +0.99%
Total 93.52% 93.54% 🟢 +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

@chrikrah chrikrah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@UncleSaurus nothing in the signing code blocks this for 2.0.0. On main a scope-less requirement such as {"schemes": {"key": {}}} never reaches the signed bytes, so deleting it keeps the signature valid.

$ .venv/bin/python -I probe/strip.py   # sign, del signed.security_requirements[:], verify
# head d11c05d
card without its apiKey requirement: InvalidSignaturesError
# src/a2a/utils/signing.py from a2dbedd (merge base)
card without its apiKey requirement: signature VERIFIES

$ .venv/bin/python -m pytest -q -p no:cacheprovider tests/utils/test_signing_presence.py tests/utils/test_signing.py tests/utils/test_jcs.py tests/server/routes/test_agent_card_routes.py
223 passed, 6 warnings in 5.50s            # d11c05d, CPython 3.10
15 failed, 208 passed, 6 warnings in 2.71s # signing.py from a2dbedd, your tests kept
# not run: the full suite, the JS SDK, a real CDN

non-blocking: #1029 bumps past patch with an empty Release-As: commit, which leaves nothing to undo. I'd drop the release-as pin.

non-blocking: keeping [{}] signs [{}, X] apart from [X], but chopmob-cloud found it is the last byte difference from JS-signed cards, and cross-SDK verification is why this waits for 2.0.0. Could that be pinned on a2aproject/A2A#2122 before merge?

As for 1.3.0 (a2aproject#1389), the release owner sets 2.0.0 with an empty commit
that ends with a Release-As line, so no config override is left to remove.
@UncleSaurus

Copy link
Copy Markdown
Author

Thanks @chopmob-cloud for testing this against the frozen card, and @chrikrah for the review and the probe.

@chopmob-cloud, you have the mechanism right. On d11c05d, securityRequirements: [{}] stays in the canonical bytes, and that accounts for the whole 28-byte difference. Keeping it is intentional, so B. The element is present, so §8.4.1 keeps it, and as @chrikrah's probe shows, that is also what makes removing a scope-less requirement break the signature.

Whether an element that becomes empty should collapse is still open in a2aproject/A2A#2122, and a2aproject/a2a-tck#246 records both readings as S1-009 and S1-010. I agree this should follow that decision before it merges. I'm converting it to draft until #2122 settles, and I'll match whatever it decides.

@chrikrah, agreed on the release-as pin, and c2faafc drops it. As for 1.3.0 (#1389), the release owner can set 2.0.0 with an empty Release-As commit, so there is no override to remove afterwards.

@UncleSaurus
UncleSaurus marked this pull request as draft November 5, 2026 14:40

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

3 participants