Skip to content

fix(signing): canonicalize agent cards per RFC 8785 - #1193

Open
astrogilda wants to merge 3 commits into
a2aproject:mainfrom
astrogilda:fix/rfc8785-canonicalization-agent-card
Open

fix(signing): canonicalize agent cards per RFC 8785#1193
astrogilda wants to merge 3 commits into
a2aproject:mainfrom
astrogilda:fix/rfc8785-canonicalization-agent-card

Conversation

@astrogilda

Copy link
Copy Markdown
Contributor

Fixes #1174.

The _canonicalize_agent_card function builds the bytes that a signature covers, so whatever it produces is what a verifier in another SDK has to reproduce exactly. Today it ends in one call:

json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True)

That does not produce RFC 8785 on any of the three axes the scheme exists to fix, and I ran each one against the code as it stands before changing it rather than reasoning about it. A card named Café Agent serializes its name as {"name":"Caf\u00e9 Agent"}, where JCS section 3.2.2.2 asks for the literal UTF-8 bytes {"name":"Café Agent"} and escapes only the C0 range, the quote and the backslash. sort_keys=True orders keys by code point, where section 3.2.3 asks for UTF-16 code unit order, so a card whose extension params carry the keys U+1F600 and U+FF01 comes out in the opposite order from every conforming implementation. And repr formats numbers, where section 3.2.2.3 asks for the ECMAScript Number::toString algorithm, so an extension param of 0.000001 is written 1e-06 and an integral param of 1 is written 1.0. Every one of those four canonical forms disagrees with an independent implementation of the RFC; after the change all four agree byte for byte.

There is a fourth divergence underneath the three in the issue, and it is the one that would have made a fix on the first three ineffective. The signer parsed the canonical string back into a dict and handed the dict to jwt.encode, which serializes its payload with its own json.dumps and therefore re-applies ensure_ascii=True to bytes that had just been canonicalized. The canonical form would have been correct and the bytes actually signed would still have been escaped. The signer now hands the canonical bytes to the JWS layer directly. The protected header is byte-identical either way, which the tests assert against a jwt.encode reference so that this cannot regress into a silent compatibility break in the header a verifier reads kid and alg out of.

On the question you left open, of vendoring a package versus writing the algorithm here: I wrote it here, and I put the package in as a test-only oracle instead, because that is strictly stronger than either option on its own. The rfc8785 package on PyPI is Trail of Bits' implementation, Apache-2.0, a pure-Python py3-none-any wheel with no runtime dependencies of its own and requires-python >=3.8, so it installs cleanly across the whole 3.10 to 3.14 matrix; its most recent release is 0.1.4 from 2024-09-27, which for a finished implementation of a frozen RFC is a reasonable place to sit rather than a warning sign. The reason not to make it a runtime dependency of a2a-sdk[signing] is that the signing extra is the code that decides whether a card is trustworthy, and a dependency there is one more package an attacker has to compromise to change what a signature means. The reason to use it anyway is that if it were the implementation, nothing would be checking it. As the oracle it checks this implementation on every accepted vector, alongside a corpus whose expected values two other implementations already agree on, so the change lands with three-way agreement instead of one-way trust. The expensive half is number formatting and I did not write that from scratch either: it is adapted from the reference implementation at cyberphone/json-canonicalization, Apache-2.0, credited in the module docstring, and then checked against the oracle over twenty thousand seeded random doubles and every mantissa-and-exponent pair near the notation thresholds. If you prefer that the SDK depend on rfc8785 at runtime, say so and I will make that swap; it is a one-line change to _canonicalize_agent_card and the entire test suite here stays valid, because the tests are written against the RFC rather than against this implementation.

The tests are lifted rather than invented. The 57 vectors in tests/utils/jcs_vectors.json come verbatim from the language-neutral a2a-jcs-v01 corpus proposed in a2aproject/a2a-tck#228, which is 47 accept, 10 reject, and five groups covering signature exclusion, key ordering, string serialization, number serialization, and arrays and nesting. Each vector carries its expected canonical form as a UTF-8 hex string, so a transcription mistake cannot hide inside an editor's encoding, and each expected value was produced by two independent implementations written by neither SDK author which agree byte for byte. I verified the lift programmatically rather than by eye: all 57 ids present, no extras, and zero mismatches on disposition, input and expected hex against the vector files in that PR. On top of the corpus the suite adds the cases this ecosystem has already found implementations disagreeing on: unpaired surrogates in keys and in values, noncharacters, characters above the BMP, U+2028 and U+2029, negative zero, denormals, the 1e21 and 1e-7 notation boundaries, integers past the exact-integer range, and nesting on both sides of the depth bound. That is 165 tests, and the full suite is 1950 passed with ruff check, ruff format --check and ty check all clean.

Then I tried to break it, which is where the depth bound comes from. Nesting is attacker-controlled: AgentExtension.params is a google.protobuf.Struct and nests arbitrarily, and an unbounded recursive serializer turns a hostile card into a stack exhaustion in whoever verifies it rather than a rejected signature. Serialization stops at 128 levels, and the _clean_empty helper needed the same bound because it runs first and would otherwise be the crash site while the serializer's bound sat unreachable behind it. A card past the bound now fails as InvalidSignaturesError on the verify path rather than as a new exception type at callers. I also checked the mutations: reversing the key order to code point, restoring ensure_ascii, restoring repr for numbers, removing the depth cap, emitting -0, and removing the surrogate rejection each turn the suite red, at 7, 35, 19, 4, 4 and 6 failures respectively. These tests fail for the reasons they claim to exist.

Two things worth knowing that are not this change. The compatibility break is real but narrow, and I measured its edges rather than describing them: a pure-ASCII card with no extension params canonicalizes identically before and after, and so does one whose extension params are ASCII strings, so those signatures keep verifying. A card with any non-ASCII text anywhere, or any numeric extension param, produces different bytes and its existing signatures will not verify, and that is the point: those are exactly the cards that do not verify in other implementations today. Separately, while attacking the signing path I found that _clean_empty lets a card carry content the signature does not cover. A card whose extension params include "policy": "" canonicalizes to the same bytes as a card with no policy key at all, so a signature made over the second verifies the first. That behaviour predates this change and fixing it would be a second and larger break, so I have deliberately left it alone here; I have a reproducer and I am happy to open it as its own issue if you want it tracked.

The signature covers a canonical serialization of the card, so the bytes
produced by _canonicalize_agent_card are the bytes that get signed.
json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True) is not
RFC 8785 on any of the three axes the scheme fixes:

- it escapes every non-ASCII character, where JCS section 3.2.2.2
  requires literal UTF-8 and escapes only the C0 range, quote and
  backslash;
- sort_keys=True orders keys by code point, where JCS section 3.2.3
  requires UTF-16 code unit order, and the two disagree for every key
  containing a character above the BMP;
- repr formats numbers, where JCS section 3.2.2.3 requires the
  ECMAScript Number::toString algorithm, so 0.000001 is written 1e-06
  and an integral double keeps a trailing .0.

A card signed by this SDK therefore fails verification in any
implementation that follows the specification, and vice versa.

Add a canonicalizer that implements the scheme and route
_canonicalize_agent_card through it. The signer now hands the canonical
bytes to the JWS layer directly rather than parsing them back into a
dict, because the JWT layer re-serializes a dict payload with its own
json.dumps and reintroduces the ASCII escaping the canonicalizer just
removed.

Bound the recursion. Nesting is attacker-controlled through
AgentExtension.params, which is a google.protobuf.Struct, and an
unbounded recursive serializer turns a deeply nested card into a stack
exhaustion in whoever verifies it. Both _clean_empty and the serializer
stop at 128 levels; _clean_empty needs the bound too because it runs
first and would otherwise be the crash site.

Values with no canonical form are refused rather than mangled:
unpaired surrogates, non-string keys, non-finite numbers, and integers
outside the range a JSON number represents exactly.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
…ation

The 57 vectors are lifted verbatim from the language-neutral a2a-jcs-v01
corpus proposed for the TCK, so they check conformance to RFC 8785
rather than agreement with this SDK's own output. Every expected value
in that corpus was produced by two independent implementations written
by neither SDK author, and they agree byte for byte; the vectors carry
the expected canonical form as a UTF-8 hex string so a transcription
error cannot hide inside an editor's encoding.

Three further layers sit on top of the corpus. Each accepted vector is
also compared against a third implementation at run time, so a mistake
shared between this canonicalizer and its own test data still shows up.
A seeded sweep of twenty thousand random doubles and a sweep of every
mantissa-exponent pair near the notation thresholds cover the part of
ECMAScript number formatting no hand-written table reaches. And the
hostile inputs are tested rather than assumed: unpaired surrogates in
keys and in values, noncharacters, characters above the BMP, the line
and paragraph separators, negative zero, denormals, the 1e21 and 1e-7
boundaries, integers past the exact-integer range, and nesting on both
sides of the depth bound.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@astrogilda
astrogilda requested a review from a team as a code owner August 20, 2026 02:32
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/utils/signing.py 95.38% 95.77% 🟢 +0.39%
src/a2a/utils/_jcs.py (new) 100.00%
Total 92.97% 93.10% 🟢 +0.12%

Generated by coverage-comment.yml

The repository's spell check rejected one word in a docstring. Rewording it
costs nothing and keeps the change out of the project's own dictionary, which a
contributor should not be editing to land a fix.

The meaning is unchanged: comparing big-endian UTF-16 encodings byte by byte is
what makes the sort equivalent to comparing code unit sequences numerically.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
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.

[Bug]: _canonicalize_agent_card is not RFC 8785, so signed cards with non-ASCII content fail cross-SDK verification

1 participant