Repository navigation
Signing/verification canonicalization drops REQUIRED fields at their default value, breaks cross-SDK Agent Card signature verification #1278
Description
Activity
- addedcomponent: coreIssues related to base data models, auth, gRPC interfaces, observability, and fundamental utilities.Issues related to base data models, auth, gRPC interfaces, observability, and fundamental utilities.
on Sep 28, 2026 Independent data point from the Go side of this same defect family, because the fix here has a cross-SDK cost worth seeing before it lands.
I reproduced this on a third-party frozen card (the one from #445):
gate_card_20260928.json, 9420 bytes, sha2562df33ff1…, signed by the JS SDK with a production key, with the matching committed JWKS. Usinga2a-pythonat0d5473ca(the commit in your permalink), same card, no network needed — the JWKS is a file.What
_canonicalize_agent_cardproduces today on that card:a2a-python @ 0d5473ca 6410 bytes c5d5384a19f3a15c761ff93bf9fec892ec99615b6356d7675fda5b79286b59b1That is byte-identical to what the JS SDK produces for the same card (
canonicalizeAgentCard), and the JS-signed signature verifies against exactly those bytes. So the empty-removal + implicit-presence omission you are proposing to change is not incidental — it is the thing that makes Python and JS currently agree with each other.The two steps you identify are both load-bearing for that. Splitting them out (same card, same commit):
MessageToDict(always_print=False) + _clean_empty → 6410 c5d5384a… (= today's output) MessageToDict(always_print=True) + no clean → 6638 db15bd6c… (rule-1 style) MessageToDict(always_print=True) + _clean_empty → 6444 bf28623a…So
always_print_fields_with_no_presence=Truealone moves the output 228 bytes away from the form the JS SDK signs. On this card those 228 bytes are exactly four leaves, and none of them is a REQUIRED field:+ /capabilities/extensions/0/required = false + /capabilities/extensions/1/required = false + /supportedInterfaces/0/tenant = "" + /supportedInterfaces/1/tenant = ""That is the part I would flag: rule 1 is about REQUIRED fields at their default value, but
always_print_fields_with_no_presenceprints every implicit-presence field, which overshoots the requirement and takes Python out of the JS agreement as a side effect. Scoping the change to actually-REQUIRED fields (sodescription: ""/skills: []survive in the payload) keeps the rest of the form intact.Note also that on this particular card the defect in #1278 does not manifest —
description,name,versionare non-empty andskillshas 5 entries. That is presumably why JS and Python look aligned today, and it means the divergence only appears on cards where a REQUIRED field sits at its default: exactly the case your issue describes, and exactly the case that needs to be in whatever test lands.Related threads, so the parts can be decided in one place rather than per SDK: #445 (Go: canonicalizing the served JSON as-is gives 6852 bytes and fails the JS signature; projecting through the message and dropping empty values gives 6410 and passes), A2A #2122 (§8.4.1 is under-determined about which form a new signature must use), and a2a-tck #245 (a rule-1 conformance corpus: R1-003/R1-004 isolate exactly the
description: ""/skills: []case — every REQUIRED field present except those two, so the competing resolutions differ in precisely them — plus a MUST-REJECT third reading). Whichever way the spec call goes, a single-SDK fix moves the break to that SDK's boundary instead of removing it — Python and JS agree today, Go does not.Reproduction, if useful:
a2a-python@0d5473ca,pip install -e ., load the card withignore_unknown_fields=True(the card carriesurl,protocolVersion,preferredTransport,compensation, which this commit's proto does not have —urlis rejected by a strictParse), then call_canonicalize_agent_cardand printlen(utf8)+ sha256. Same for the two variants. I did not run the JS SDK locally; the JS bytes there are the ones published in #445 and independently reconstructed in Go.@kuangmi-bit thanks, this is useful. Your result rules out
always_print_fields_with_no_presence=Trueas a fix by itself. On that JS-signed card it adds four non-REQUIRED leaves and changes bytes that Python and JS currently agree on.It does not rule out the descriptor-aware approach from the issue. The remaining question is Rule 1 itself. a2aproject/A2A#2122 is still deciding how REQUIRED fields at their default value should be represented, and a2aproject/a2a-tck#245 records the competing outcomes for
description: ""andskills: [].So I'd keep #1278 open, but not change Python's canonical bytes until that rule is settled. Once it is, the Python fix can preserve exactly the fields the chosen rule requires without pulling in unrelated implicit-presence defaults.
Thanks for catching the overshoot.
Hi @aeoess and @kuangmi-bit,
Thank you both for the thorough analysis and for catching the subtle edge cases around protobuf presence and canonicalization.
Your diagnosis is correct. The current pipeline insrc/a2a/utils/signing.py(MessageToDict+ unconditional_clean_empty) operates without protobuf descriptor awareness, dropping empty values even when fields are designatedREQUIREDper Section 8.4.1 of the A2A specification.
As @kuangmi-bit rightly demonstrated, settingalways_print_fields_with_no_presence=Trueovershoots by retaining non-required default fields, which would break canonical byte compatibility with cards currently in circulation.
The proper fix requires inspecting the field descriptors onAgentCardto selectively preserve onlyREQUIREDfields when empty, while continuing to prune optional default fields.I could assign this issue to either of you if you want to contribute for a fix.
Thanks @rohityan — I'd like to take it, with one caveat about overlap.
@aeoess wrote the descriptor-aware approach in the issue and your comment endorses it, so a competing PR from me would be the wrong shape. Two clean splits instead:
If aeoess implements — I'll take the verification half, which is the piece I can do least ambiguously: run their patch against the frozen third-party card from a2aproject/a2a-go#445 (9420 bytes, sha256
2df33ff1…, JS-signed, with the matching committed JWKS) and report (a) REQUIRED-only preservation adds exactly the REQUIRED leaves and nothing else, (b) cards with no empty REQUIRED field keep the bytes Python and JS currently agree on (6410/c5d5384a…), and (c) each of the four non-REQUIRED leaves the blanket flag pulled in stays absent (6638/db15bd6c…was that overshoot).If aeoess would rather not — I'll implement the descriptor-aware canonicalization in
src/a2a/utils/signing.pymyself and hand them the same verification.Either way, on the Rule 1 dependency: the descriptor-aware machinery (walk the
AgentCarddescriptors, keep pruning optional defaults, keep the REQUIRED set addressable) can land independently of a2aproject/A2A#2122; only the "preserve an empty REQUIRED field" branch depends on the rule the TSC picks there. I would rather build the rule-independent half first and gate the other behind #2122 than sit on it — say the word if you would prefer to wait for that decision.One question so the assignment is unambiguous: which of the two halves do you want me on?
@rohityan yes, please assign it to me.
@kuangmi-bit thank you. I'll take the implementation and would really appreciate your verification against the frozen JS-signed card.
I'll keep the patch split so the a2aproject/A2A#2122 dependency is explicit:
- descriptor-aware REQUIRED-field handling, while optional defaults keep being pruned and cards with no empty REQUIRED field keep today's canonical bytes.
- the Rule 1 behavior that preserves an empty REQUIRED field, with the §8.4.1 worked example and empty
description/skillscases.
I checked current main at
fad0482: the REQUIRED annotation is available on the generated descriptors, whileAgentInterface.tenantandAgentExtension.requiredare not REQUIRED, so the four leaves from the blanketalways_print_fields_with_no_presenceapproach stay out.Whether the second commit lands now or waits for the a2aproject/A2A#2122 decision is your call. I won't treat that issue as resolved by this patch.
@aeoess — sounds good: implementation is yours, I'll take the verification as an independent pass, run against the frozen fixture rather than through your test harness.
Fixture + anchors, taken from the #445 comment that published them (not from inside the package):
artifact bytes sha256 gate_card_20260928.json(gate 0.4.17, JS-signed, two ES256 signatures, kidhs-2026-09)9420 2df33ff120745a7f46b8afc1378d72b437b3f05b4db61c8d8b3655c581584455gate_jwks_20260928.json256 692fd49da681cc0d0bda9ad46963fa5f45957a59d10f84998e9b2e4061209d3cReference form I compare against: the JS SDK bytes for that card, 6410 /
c5d5384a19f3a15c761ff93bf9fec892ec99615b6356d7675fda5b79286b59b1.What I will run on your branch (offline, no network; card and JWKS read from those committed files):
- Rule, not field. On the frozen card the output must be 6410 /
c5d5384a…byte-for-byte — the projection must not disturb a card that has no empty REQUIRED field. - Overshoot stays dead. The four leaves the blanket flag pulled in —
/capabilities/extensions/{0,1}/required,/supportedInterfaces/{0,1}/tenant— must remain absent. (That form measured 6638 /db15bd6c…at0d5473ca; it is the regression I will be watching for.) - The case the issue is about. On a variant where a REQUIRED field sits at its default (
description: "",skills: [], taken from a2a-tck#245 R1-003/R1-004), the projection must include those leaves — and I will list the added paths, not just the byte delta, so a key-specific special case would be visible. - The rule reaches nested empties. On the same variant
securityRequirements: [{}]must still collapse (empty values removed recursively, an element that becomes empty collapses), per the exchange with @ogasurfproject-jpg. - End-to-end. The bytes your canonicalizer produces for the frozen card must verify that card's
signatures[1]with the committed JWKS (ES256). That is the contract that matters: a JS-signed card verifies in Python.
Report shape: an anchor→result table pinned by the input hashes above, the exact command, and any behavioural difference; happy to post it here or on the PR, whichever you prefer. Send the branch/commit when it is pushed and I will run it the same day. If it helps, verify the rule-independent half first (descriptor walk + optional-default pruning) while the #2122-gated branch stays separate, as you described.
- Rule, not field. On the frozen card the output must be 6410 /
Publisher confirmation for the fixture, since it is our card: both files are unchanged from what a2aproject/a2a-go#445 published, and they are committed at https://github.com/ogasurfproject-jpg/horizon-shield/tree/main/workers/a2a-card-sign/interop-go/fixtures (9420 / 2df33ff1… and 256 / 692fd49d…, matching the table above).
Please run against the files, not the live URL. The gate has moved to 0.4.20 since that snapshot, so https://gate.horizonshield.dev/.well-known/agent-card.json now serves different bytes and a different canonical form, even though it is signed with the same key (kid hs-2026-09). The fixture will not be re-signed or edited. If the key ever rotates, the old public key stays listed in https://gate.horizonshield.dev/.well-known/key-history.json, so signatures[1] on the frozen card remains checkable.
For the verification pass: the same frozen card plus six single-field cases are now in a public matrix where all three SDKs sign and verify each other, with per-SDK canonical hashes, so the before and after of this patch can be compared row by row. Today Python and JS agree on every case and Go disagrees with both as soon as any empty value is present; the section 8.4.1 worked example is reproduced by none of the three.
https://github.com/ogasurfproject-jpg/horizon-shield/tree/main/workers/a2a-card-sign/interop-matrix- added a commit that references this issue
on Oct 1, 2026 @kuangmi-bit the branch is up as #1287, CI green. The head to verify is
cdee28e53f2166ca04fc360868cae9fc2b7a3182. It has two commits.3f907120is the descriptor traversal only, with no canonical bytes change.cdee28e5is the Rule 1 behavior, kept separate for [Bug]: §8.4.1 canonicalization is under-determined - two SDKs produce different bytes for the same card, and neither reproduces the section's own worked example A2A#2122.
I ran the frozen card and the #1286 tests on it and put the numbers in the PR. I would still rather your pass stand as the independent one. Posting your table on the PR works for me.
Thank you all for your analysis and discussion. The inconsistencies you found are real.
There are really two problems tangled together here:
1) The SPEC 8.4.1 example itself was wrong
The card was invalid (
skills: []violates SPEC 5.7, and several REQUIRED fields were missing) and its canonical output couldn't be reproduced from its input.
That part is a clean, non-breaking fix - a2aproject/A2A#2318 and a2aproject/A2A#2319 are supposed to replace it with a valid, reproducible example.2) Aligning the SDKs so empty/default REQUIRED fields verify cross-SDK
It's the harder one: it changes the signed bytes, which breaks existing signatures, so it can't ship outside a major release (It's a breaking change)
On the SDK side, #1279 is supposed to add a non-breaking warning when a card has missing or empty REQUIRED fields.
Thanks, the split makes sense, and the signer-side SHOULD I suggested on A2A#2122 points the same way as the #1279 warning.
Once #1279 adds the warning in signing._canonicalize_agent_card, I'll run it over the s1 and s2 cards in a2a-card-sign-v01 (a2a-tck#246) and post which cards warn. The expectation is that it fires on exactly the cards where the shipping SDKs produce different bytes and stays silent on the s0 controls.
- added a commit that references this issue
on Oct 7, 2026
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.