From 7122a4855b300f1b81e821cf32b328a69f253413 Mon Sep 17 00:00:00 2001 From: Sankalp Gilda Date: Wed, 19 Aug 2026 21:16:16 -0400 Subject: [PATCH 1/3] fix(signing): serialize the Agent Card with RFC 8785 canonical JSON 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 --- .github/actions/spelling/allow.txt | 8 + pyproject.toml | 4 + src/a2a/utils/_jcs.py | 227 +++++++++++++++++++++++++++++ src/a2a/utils/signing.py | 56 +++++-- uv.lock | 23 ++- 5 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 src/a2a/utils/_jcs.py diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 1701f14ef..dc1b6831a 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -37,9 +37,12 @@ coc codegen coro culsans +cyberphone datamodel datapart deepwiki +denormal +denormals drivername DSNs dunders @@ -56,6 +59,7 @@ GBP genai getkwargs gle +gowebpki GVsb hazmat HS256 @@ -91,6 +95,8 @@ middleware mikeas mockurl mysqladmin +noncharacter +noncharacters notif npx oauthoidc @@ -128,6 +134,7 @@ resub rmi RS256 RUF +Rundgren SECP256R1 SFIXED SLF @@ -135,6 +142,7 @@ socio sse starlette Starlette +stringified subgids subuids sut diff --git a/pyproject.toml b/pyproject.toml index 066983a36..9804849dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,6 +114,10 @@ dev = [ "pytest-mock>=3.14.0", "pytest-xdist>=3.6.1", "respx>=0.20.2", + # Test-only oracle. The RFC 8785 conformance tests check the canonicalizer + # against an independent implementation, so a mistake shared between the + # implementation and its tests still shows up. Not a runtime dependency. + "rfc8785>=0.1.4", "ruff>=0.12.8", "uv-dynamic-versioning>=0.8.2", "types-protobuf<7", diff --git a/src/a2a/utils/_jcs.py b/src/a2a/utils/_jcs.py new file mode 100644 index 000000000..ce10765c7 --- /dev/null +++ b/src/a2a/utils/_jcs.py @@ -0,0 +1,227 @@ +"""RFC 8785 JSON Canonicalization Scheme (JCS). + +The Agent Card signature covers a canonical serialization of the card, so the +bytes produced here are the bytes that get signed. `json.dumps` cannot produce +them: it escapes non-ASCII by default, orders keys by code point rather than by +UTF-16 code unit, and formats numbers with `repr` rather than with the +ECMAScript `Number::toString` algorithm. + +Number formatting follows ECMA-262 7.1.12.1 as amended by RFC 8785 section +3.2.2.3. That part of the algorithm is adapted from Anders Rundgren's reference +implementation, which is published under the Apache License 2.0: +https://github.com/cyberphone/json-canonicalization + +Serialization is depth-limited. Nesting is attacker-controlled through +`AgentExtension.params`, which is a `google.protobuf.Struct` and so may nest +arbitrarily, and an unbounded recursive serializer turns that into a crash in +whatever process verifies the card. +""" + +from __future__ import annotations + +import math +import re + +from typing import Any + + +MAX_DEPTH = 128 +"""Maximum object/array nesting accepted by `canonicalize`. + +Deep enough that no plausible Agent Card reaches it, shallow enough that the +recursion cannot exhaust the interpreter stack. +""" + +# JSON numbers are IEEE 754 double-precision floats (RFC 8785 section 3.2.2.3), +# so integers outside this range have no interoperable representation. +_SAFE_INT_MAX = 2**53 - 1 +_SAFE_INT_MIN = -(2**53) + 1 + +# ECMA-262 7.1.12.1 steps 6 and 8: outside this exponent window a number is +# written in exponential notation, inside it the digits are written out in full. +_EXPONENT_EXPANSION_CEILING = 21 +_EXPONENT_EXPANSION_FLOOR = -7 + +# RFC 8785 section 3.2.2.2: escape the two mandatory characters and the C0 +# control range, and emit everything else as literal UTF-8. +_ESCAPE_RE = re.compile(r'[\x00-\x1f\\"]') +_ESCAPE_MAP = { + '\\': '\\\\', + '"': '\\"', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', +} +for _codepoint in range(0x20): + _ESCAPE_MAP.setdefault(chr(_codepoint), f'\\u{_codepoint:04x}') +del _codepoint + + +class CanonicalizationError(ValueError): + """Raised when a value has no RFC 8785 canonical form.""" + + +def canonicalize(obj: Any) -> str: + """Serializes `obj` to its RFC 8785 canonical form. + + Args: + obj: A JSON value: `None`, `bool`, `int`, `float`, `str`, or a list or + dict of the same. Dict keys must be strings. + + Returns: + The canonical serialization. The result is `str` rather than `bytes` + for the caller's convenience; RFC 8785 defines the canonical form as + the UTF-8 encoding of this string. + + Raises: + CanonicalizationError: If `obj` contains a value with no canonical + form, a non-string key, unpaired surrogates, or nesting deeper + than `MAX_DEPTH`. + """ + out: list[str] = [] + _write(obj, out, 0) + canonical = ''.join(out) + try: + # RFC 8785 canonical output is UTF-8. Round-tripping here rejects + # unpaired surrogates, which have no UTF-8 encoding, rather than + # deferring the failure to whoever encodes the return value. + canonical.encode('utf-8') + except UnicodeEncodeError as e: + raise CanonicalizationError( + 'value contains text that is not valid Unicode' + ) from e + return canonical + + +def _write(obj: Any, out: list[str], depth: int) -> None: + """Appends the canonical form of `obj` to `out`.""" + if depth > MAX_DEPTH: + raise CanonicalizationError( + f'nesting exceeds the maximum depth of {MAX_DEPTH}' + ) + + if isinstance(obj, (list, tuple)): + out.append('[') + for index, element in enumerate(obj): + if index: + out.append(',') + _write(element, out, depth + 1) + out.append(']') + elif isinstance(obj, dict): + out.append('{') + for index, (key, value) in enumerate(_sorted_items(obj)): + if index: + out.append(',') + out.append(_quote(key)) + out.append(':') + _write(value, out, depth + 1) + out.append('}') + else: + out.append(_format_scalar(obj)) + + +def _format_scalar(obj: Any) -> str: + """Returns the canonical form of a JSON scalar.""" + if obj is None: + return 'null' + # bool is a subclass of int, so it has to be tested first. + if isinstance(obj, bool): + return 'true' if obj else 'false' + if isinstance(obj, int): + if obj < _SAFE_INT_MIN or obj > _SAFE_INT_MAX: + raise CanonicalizationError( + f'{obj} is outside the range of integers a JSON number can ' + 'represent exactly' + ) + return str(obj) + if isinstance(obj, float): + return _format_number(obj) + if isinstance(obj, str): + return _quote(obj) + raise CanonicalizationError( + f'{type(obj).__name__} has no JSON representation' + ) + + +def _sorted_items(obj: dict[Any, Any]) -> list[tuple[str, Any]]: + """Sorts a dict's items by UTF-16 code unit, per RFC 8785 section 3.2.3. + + Comparing big-endian UTF-16 encodings bytewise is equivalent to comparing + the code unit sequences numerically, which is what the RFC requires and + what `sorted(..., key=str)` does not do: code point order and code unit + order disagree for every key containing a character above the BMP. + """ + try: + return sorted(obj.items(), key=lambda kv: kv[0].encode('utf-16-be')) + except AttributeError as e: + raise CanonicalizationError('object keys must be strings') from e + except UnicodeEncodeError as e: + raise CanonicalizationError( + 'object key contains text that is not valid Unicode' + ) from e + + +def _quote(value: str) -> str: + """Returns the canonical form of a JSON string.""" + return '"' + _ESCAPE_RE.sub(lambda m: _ESCAPE_MAP[m.group(0)], value) + '"' + + +def _format_number(value: float) -> str: + """Formats a float per ECMA-262 7.1.12.1, as RFC 8785 section 3.2.2.3 requires.""" + if math.isnan(value) or math.isinf(value): + raise CanonicalizationError(f'{value} is not a JSON number') + + # Covers -0.0, which ECMAScript renders as "0". + if value == 0: + return '0' + + if value < 0: + return '-' + _format_number(-value) + + # Adapted from the reference implementation; see the module docstring. + stringified = str(value) + + exponent_str = '' + exponent_value = 0 + separator = stringified.find('e') + if separator > 0: + exponent_str = stringified[separator:] + if exponent_str[2:3] == '0': + # Python pads the exponent to two digits; ECMAScript does not. + exponent_str = exponent_str[:2] + exponent_str[3:] + stringified = stringified[0:separator] + exponent_value = int(exponent_str[1:]) + + first = stringified + dot = '' + last = '' + separator = stringified.find('.') + if separator > 0: + dot = '.' + first = stringified[:separator] + last = stringified[separator + 1 :] + + if last == '0': + # Python writes an integral float as "1.0"; ECMAScript writes "1". + dot = '' + last = '' + + if 0 < exponent_value < _EXPONENT_EXPANSION_CEILING: + # Values up to 1e21 are written out in full rather than in exponential + # notation. + first += last + last = '' + dot = '' + exponent_str = '' + first += '0' * (exponent_value - len(first) + 1) + elif _EXPONENT_EXPANSION_FLOOR < exponent_value < 0: + # Values down to 1e-7 are written as 0.000... rather than exponentially. + last = first + last + first = '0' + dot = '.' + exponent_str = '' + last = '0' * (-exponent_value - 1) + last + + return f'{first}{dot}{last}{exponent_str}' diff --git a/src/a2a/utils/signing.py b/src/a2a/utils/signing.py index d3b1e696d..c85a80072 100644 --- a/src/a2a/utils/signing.py +++ b/src/a2a/utils/signing.py @@ -9,6 +9,7 @@ try: import jwt + from jwt import api_jws from jwt.api_jwk import PyJWK from jwt.exceptions import PyJWTError from jwt.utils import base64url_decode, base64url_encode @@ -20,6 +21,7 @@ ) from e from a2a.types import AgentCard, AgentCardSignature +from a2a.utils._jcs import MAX_DEPTH, CanonicalizationError, canonicalize class SignatureVerificationError(Exception): @@ -69,16 +71,19 @@ def create_agent_card_signer( def agent_card_signer(agent_card: AgentCard) -> AgentCard: """Signs agent card.""" canonical_payload = _canonicalize_agent_card(agent_card) - payload_dict = json.loads(canonical_payload) - jws_string = jwt.encode( - payload=payload_dict, + # The JWS payload has to be the canonical bytes themselves. Handing a + # parsed dict to the JWT layer would let PyJWT re-serialize it with its + # own json.dumps, which escapes non-ASCII, so the bytes signed would + # differ from the bytes the verifier canonicalizes and checks. + jws_string = api_jws.encode( + payload=canonical_payload.encode('utf-8'), key=signing_key, algorithm=protected_header.get('alg', 'HS256'), - headers=dict(protected_header), # ty:ignore[no-matching-overload] + headers=dict(protected_header), ) - # The result of jwt.encode is a compact serialization: HEADER.PAYLOAD.SIGNATURE + # The result is a compact serialization: HEADER.PAYLOAD.SIGNATURE protected, _, signature = jws_string.split('.') agent_card_signature = AgentCardSignature( @@ -117,6 +122,20 @@ def signature_verifier( if not agent_card.signatures: raise NoSignatureError('AgentCard has no signatures to verify.') + # The canonical form does not depend on which signature is being + # checked, so it is computed once. A card with no canonical form has no + # verifiable signature, and reporting that as InvalidSignaturesError + # keeps every failure on this path a SignatureVerificationError. + try: + canonical_payload = _canonicalize_agent_card(agent_card) + except CanonicalizationError as e: + raise InvalidSignaturesError( + 'AgentCard cannot be canonicalized for verification' + ) from e + encoded_payload = base64url_encode( + canonical_payload.encode('utf-8') + ).decode('utf-8') + for agent_card_signature in agent_card.signatures: try: # get verification key @@ -128,11 +147,6 @@ def signature_verifier( jku = protected_header.get('jku') verification_key = key_provider(kid, jku) - canonical_payload = _canonicalize_agent_card(agent_card) - encoded_payload = base64url_encode( - canonical_payload.encode('utf-8') - ).decode('utf-8') - token = f'{agent_card_signature.protected}.{encoded_payload}.{agent_card_signature.signature}' jwt.decode( jwt=token, @@ -150,18 +164,30 @@ def signature_verifier( return signature_verifier -def _clean_empty(d: Any) -> Any: - """Recursively remove empty strings, lists and dicts from a dictionary.""" +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)) is not None + 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)) is not None + 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: @@ -179,4 +205,4 @@ def _canonicalize_agent_card(agent_card: AgentCard) -> str: # Recursively remove empty values cleaned_dict = _clean_empty(card_dict) - return json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True) + return canonicalize(cleaned_dict) diff --git a/uv.lock b/uv.lock index 789084829..942264d96 100644 --- a/uv.lock +++ b/uv.lock @@ -90,6 +90,7 @@ dev = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "respx" }, + { name = "rfc8785" }, { name = "ruff" }, { name = "trio" }, { name = "ty" }, @@ -158,6 +159,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "respx", specifier = ">=0.20.2" }, + { name = "rfc8785", specifier = ">=0.1.4" }, { name = "ruff", specifier = ">=0.12.8" }, { name = "trio" }, { name = "ty", specifier = ">=0.0.34" }, @@ -172,9 +174,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -744,8 +746,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -778,7 +780,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1772,6 +1774,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, ] +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "ruff" version = "0.15.16" From ccfffecd9276d969c9efac5c604b38a9057f7ffa Mon Sep 17 00:00:00 2001 From: Sankalp Gilda Date: Wed, 19 Aug 2026 21:16:30 -0400 Subject: [PATCH 2/3] test(signing): add the a2a-jcs-v01 conformance corpus for canonicalization 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 --- tests/utils/jcs_vectors.json | 685 +++++++++++++++++++++++++++++++++++ tests/utils/test_jcs.py | 496 +++++++++++++++++++++++++ 2 files changed, 1181 insertions(+) create mode 100644 tests/utils/jcs_vectors.json create mode 100644 tests/utils/test_jcs.py diff --git a/tests/utils/jcs_vectors.json b/tests/utils/jcs_vectors.json new file mode 100644 index 000000000..cfe59c842 --- /dev/null +++ b/tests/utils/jcs_vectors.json @@ -0,0 +1,685 @@ +{ + "_comment": "RFC 8785 (JCS) conformance vectors for Agent Card canonicalization. Lifted verbatim from the language-neutral a2a-jcs-v01 corpus proposed at https://github.com/a2aproject/a2a-tck/pull/228 . Each expected value was produced by two independent RFC 8785 implementations written by neither SDK author (rfc8785 0.1.4 on PyPI, and gowebpki/jcs v1.0.1 in Go), which agree byte for byte.", + "corpus": "a2a-jcs-v01", + "specRef": "https://a2a-protocol.org/latest/specification/#841-canonicalization-requirements", + "oracles": [ + "rfc8785 (PyPI, 0.1.4)", + "gowebpki/jcs (Go, v1.0.1)" + ], + "counts": { + "accept": 47, + "reject": 10, + "total": 57 + }, + "vectors": [ + { + "id": "A2-001", + "group": "a2-signatures-exclusion", + "clause": "a2a-spec-8.4.1-rule-3", + "disposition": "MUST-ACCEPT", + "rationale": "A card carrying a populated `signatures` array canonicalizes as if that key were absent entirely; the exclusion is unconditional, not conditional on the array being empty.", + "input": { + "name": "Example Agent", + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiJ9", + "signature": "abc123" + } + ] + }, + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c226e616d65223a224578616d706c65204167656e74227d" + }, + { + "id": "A2-002", + "group": "a2-signatures-exclusion", + "clause": "a2a-spec-8.4.1-rule-3", + "disposition": "MUST-ACCEPT", + "rationale": "A card carrying an EMPTY `signatures` array ([]) is excluded the same way as a populated one (A2-001): exclusion is by key presence, not by whether the value is 'interesting'. Compare directly against A6-001, which established that an empty array is otherwise a perfectly normal RFC 8785 value elsewhere in a document -- the only thing special about `signatures` is the key name.", + "input": { + "name": "Example Agent", + "capabilities": { + "streaming": true, + "pushNotifications": false + }, + "signatures": [] + }, + "canonical_utf8_hex": "7b226361706162696c6974696573223a7b22707573684e6f74696669636174696f6e73223a66616c73652c2273747265616d696e67223a747275657d2c226e616d65223a224578616d706c65204167656e74227d" + }, + { + "id": "A2-REJECT-003", + "group": "a2-signatures-exclusion", + "clause": "a2a-spec-8.4.1-rule-3", + "disposition": "MUST-REJECT", + "rationale": "A single-signature payload presented as canonical output but still carrying its own `signatures` array is self-referential: the bytes a signature covers cannot include the signature that covers them, so any canonical form containing `signatures` is definitionally wrong regardless of what RFC 8785 alone would say about it. (confirmed: the input below is valid, well-formed JSON that parses cleanly -- both RFC 8785 oracles would canonicalize it without complaint -- and is refused ONLY because it retains the excluded key, which is an a2a-specific rule RFC 8785 itself has no opinion on.)", + "input_raw": "{\"capabilities\":{\"pushNotifications\":false,\"streaming\":true},\"name\":\"Example Agent\",\"signatures\":[{\"protected\":\"eyJhbGciOiJFUzI1NiJ9\",\"signature\":\"abc123\"}]}" + }, + { + "id": "A2-REJECT-004", + "group": "a2-signatures-exclusion", + "clause": "a2a-spec-8.4.1-rule-3", + "disposition": "MUST-REJECT", + "rationale": "Even a `signatures` key holding an empty array must still be rejected in claimed-canonical output: the exclusion rule is unconditional (A2-002), so its violation is unconditional too -- an empty array does not make the self-reference acceptable, it is still the wrong key present in the wrong place. (confirmed: the input below is valid, well-formed JSON that parses cleanly -- both RFC 8785 oracles would canonicalize it without complaint -- and is refused ONLY because it retains the excluded key, which is an a2a-specific rule RFC 8785 itself has no opinion on.)", + "input_raw": "{\"capabilities\":{\"pushNotifications\":false,\"streaming\":true},\"name\":\"Example Agent\",\"signatures\":[]}" + }, + { + "id": "A3-001", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Baseline: keys already in UTF-16 code-unit order pass through unchanged.", + "input": { + "a": 1, + "b": 2, + "c": 3 + }, + "canonical_utf8_hex": "7b2261223a312c2262223a322c2263223a337d" + }, + { + "id": "A3-002", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Keys given out of order must be reordered to UTF-16 code-unit order.", + "input": { + "zebra": 1, + "apple": 2, + "mango": 3 + }, + "canonical_utf8_hex": "7b226170706c65223a322c226d616e676f223a332c227a65627261223a317d" + }, + { + "id": "A3-003", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Case sensitivity: uppercase ASCII (U+0041 'A') sorts before lowercase ASCII (U+0061 'a') as a raw code unit.", + "input": { + "apple": 1, + "Apple": 2 + }, + "canonical_utf8_hex": "7b224170706c65223a322c226170706c65223a317d" + }, + { + "id": "A3-004", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Numeric-looking keys sort as UTF-16 code-unit sequences, not as numbers: '1' < '10' < '2'.", + "input": { + "10": 1, + "2": 2, + "1": 3 + }, + "canonical_utf8_hex": "7b2231223a332c223130223a312c2232223a327d" + }, + { + "id": "A3-005", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "A BMP character above the ASCII range (U+00E9 'e with acute') sorts after every ASCII key.", + "input": { + "cafe": 1, + "caf\u00e9": 2 + }, + "canonical_utf8_hex": "7b2263616665223a312c22636166c3a9223a327d" + }, + { + "id": "A3-006", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "The a2aproject/A2A#2122 counterexample verbatim (case C3): a key containing U+1F600 (surrogate pair D83D DE00) sorts BEFORE a key containing U+FF01 (single unit FF01), because D83D < FF01 as raw UTF-16 code units, even though the code POINT 1F600 is numerically greater than FF01. Sorting by code point instead of UTF-16 code unit -- what a2a-python's sort_keys=True does -- gets this pair backwards.", + "input": { + "b\ud83d\ude00key": 1, + "b\uff01key": 2 + }, + "canonical_utf8_hex": "7b2262f09f98806b6579223a312c2262efbc816b6579223a327d" + }, + { + "id": "A3-007", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "A second astral-vs-high-BMP inversion pair, generalizing A3-006 beyond one example: U+10000 (surrogate pair D800 DC00) sorts before U+E000 (single unit E000, Private Use Area) by the same UTF-16-vs-code-point divergence.", + "input": { + "x\ud800\udc00end": 1, + "x\ue000end": 2 + }, + "canonical_utf8_hex": "7b2278f0908080656e64223a312c2278ee8080656e64223a327d" + }, + { + "id": "A3-008", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Two byte-distinct keys that render identically to a human (precomposed U+00E9 vs the combining-character sequence 'e' + U+0301) are different code-unit sequences with no normalization applied by RFC 8785; they sort as the distinct sequences they are, and the combining form (starts with plain ASCII 'e', U+0065) sorts before the precomposed form (starts with U+00E9).", + "input": { + "caf\u00e9": 1, + "cafe\u0301": 2 + }, + "canonical_utf8_hex": "7b2263616665cc81223a322c22636166c3a9223a317d" + }, + { + "id": "A3-009", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "The empty string is a valid key and, having zero code units, sorts before every non-empty key.", + "input": { + "": 1, + "a": 2 + }, + "canonical_utf8_hex": "7b22223a312c2261223a327d" + }, + { + "id": "A3-010", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Stress case: eleven keys spanning ASCII, digits and mixed case sorted together, confirming the ordering rule is stable across more than a handful of keys at once.", + "input": { + "Zulu": 1, + "alpha": 2, + "Bravo": 3, + "charlie": 4, + "Delta": 5, + "9key": 6, + "echo": 7, + "Foxtrot": 8, + "1key": 9, + "golf": 10, + "Hotel": 11 + }, + "canonical_utf8_hex": "7b22316b6579223a392c22396b6579223a362c22427261766f223a332c2244656c7461223a352c22466f7874726f74223a382c22486f74656c223a31312c225a756c75223a312c22616c706861223a322c22636861726c6965223a342c226563686f223a372c22676f6c66223a31307d" + }, + { + "id": "A3-REJECT-011", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-REJECT", + "rationale": "A key containing a lone (unpaired) high surrogate U+D800 with no following low surrogate is not valid Unicode text and has no well-formed UTF-8 encoding, so it cannot be assigned a UTF-16 code-unit sort position or emitted as literal UTF-8 (RFC 8785 sect. 3.2.2.2); a conformant canonicalizer must refuse it rather than silently pass the unpaired surrogate through or substitute a replacement character. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"b\\ud800key\": 1, \"a\": 2}" + }, + { + "id": "A3-REJECT-012", + "group": "a3-object-key-ordering", + "clause": "RFC8785-3.2.3", + "disposition": "MUST-REJECT", + "rationale": "A key containing a lone (unpaired) LOW surrogate U+DC00 with no preceding high surrogate completes A3-REJECT-011's high-surrogate case with the low-surrogate half of the pair: equally invalid Unicode, equally unencodable as well-formed UTF-8, for the same reason. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"b\\udc00key\": 1, \"a\": 2}" + }, + { + "id": "A4-001", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "The a2aproject/A2A#2122 counterexample verbatim (case C2): a non-ASCII scalar (e with acute, U+00E9) is emitted as literal UTF-8 bytes, never as a \\u00e9 escape. ensure_ascii=True in Python's json.dumps -- a2a-python's actual default -- produces the escaped form and fails this vector.", + "input": { + "name": "Caf\u00e9 Agent", + "description": "Planifie des itin\u00e9raires." + }, + "canonical_utf8_hex": "7b226465736372697074696f6e223a22506c616e6966696520646573206974696ec3a97261697265732e222c226e616d65223a22436166c3a9204167656e74227d" + }, + { + "id": "A4-002", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "Pure ASCII content is the trivial control case: no escaping needed, output equals input.", + "input": { + "name": "Example Agent" + }, + "canonical_utf8_hex": "7b226e616d65223a224578616d706c65204167656e74227d" + }, + { + "id": "A4-003", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "An astral-plane character (U+1F600 GRINNING FACE, outside the Basic Multilingual Plane, requiring a UTF-16 surrogate pair to represent but a single 4-byte UTF-8 sequence) is emitted as literal UTF-8, not as a \\ud83d\\ude00 surrogate-pair escape.", + "input": { + "note": "hello \ud83d\ude00 world" + }, + "canonical_utf8_hex": "7b226e6f7465223a2268656c6c6f20f09f988020776f726c64227d" + }, + { + "id": "A4-004", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "The mandatory escape set is exactly quote, backslash, and control characters below U+0020: this string exercises all three (a literal quote, a literal backslash, and a literal newline), each of which MUST still be escaped even though non-ASCII text is emitted literally.", + "input": { + "raw": "a \"quoted\" \\ path\nwith a newline" + }, + "canonical_utf8_hex": "7b22726177223a2261205c2271756f7465645c22205c5c20706174685c6e776974682061206e65776c696e65227d" + }, + { + "id": "A4-005", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "The empty string is a valid scalar value and serializes as a pair of quote characters with nothing between them.", + "input": { + "empty": "" + }, + "canonical_utf8_hex": "7b22656d707479223a22227d" + }, + { + "id": "A4-006", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "Every remaining C0 control character (U+0001 through U+001F, excluding the ones with short escapes like \\n and \\t) must still be escaped as \\u00XX -- control-character escaping is required regardless of the literal-UTF-8 rule for ordinary text, since control characters are never literal in a JSON string.", + "input": { + "ctrl": "a\u0001b\u001fc" + }, + "canonical_utf8_hex": "7b226374726c223a22615c7530303031625c753030316663227d" + }, + { + "id": "A4-007", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "A CJK ideograph (U+4E2D, three-byte UTF-8) is emitted as literal UTF-8, the same rule as A4-001 applied to a script outside the Latin-1 range.", + "input": { + "label": "\u4e2d\u6587" + }, + "canonical_utf8_hex": "7b226c6162656c223a22e4b8ade69687227d" + }, + { + "id": "A4-008", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "U+007F (DELETE) sits immediately above the printable ASCII range and below the C1 control block; RFC 8785 requires literal UTF-8 for it like any other non-mandatory-escape character, it is not part of the U+0000-U+001F mandatory escape set.", + "input": { + "del": "a\u007fb" + }, + "canonical_utf8_hex": "7b2264656c223a22617f62227d" + }, + { + "id": "A4-009", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "A combining diacritic (U+0301 COMBINING ACUTE ACCENT) immediately following its base character is ordinary non-ASCII text with no special JSON-string handling; it is emitted as literal UTF-8 like any other codepoint outside the escape set.", + "input": { + "combining": "e\u0301clair" + }, + "canonical_utf8_hex": "7b22636f6d62696e696e67223a2265cc81636c616972227d" + }, + { + "id": "A4-010", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "Right-to-left script content (Arabic, U+0627 ALEF through U+0629 TEH MARBUTA) is emitted as literal UTF-8 bytes; RFC 8785 has no bidi-aware transform, only byte identity.", + "input": { + "rtl": "\u0627\u0644\u0633\u0644\u0627\u0645" + }, + "canonical_utf8_hex": "7b2272746c223a22d8a7d984d8b3d984d8a7d985227d" + }, + { + "id": "A4-011", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "Forward slash is explicitly NOT in JSON's mandatory escape set (unlike some JSON encoders' optional habit of escaping it as \\/) and must be emitted literally.", + "input": { + "url": "https://example.com/a2a/v1" + }, + "canonical_utf8_hex": "7b2275726c223a2268747470733a2f2f6578616d706c652e636f6d2f6132612f7631227d" + }, + { + "id": "A4-012", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-ACCEPT", + "rationale": "A string built entirely from two adjacent astral-plane characters (both requiring surrogate pairs in UTF-16) confirms multi-character non-BMP content is handled, not just a single isolated case like A4-003.", + "input": { + "emoji_pair": "\ud83d\ude00\ud83d\ude01" + }, + "canonical_utf8_hex": "7b22656d6f6a695f70616972223a22f09f9880f09f9881227d" + }, + { + "id": "A4-REJECT-013", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-REJECT", + "rationale": "A lone (unpaired) low surrogate U+DC00 inside a STRING VALUE (not a key, unlike A3-REJECT-011/012) is not valid Unicode text and has no well-formed UTF-8 encoding; a conformant canonicalizer must refuse it rather than emit an invalid byte sequence or silently substitute U+FFFD. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"name\": \"broken \\udc00 value\"}" + }, + { + "id": "A4-REJECT-014", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-REJECT", + "rationale": "A lone low surrogate (U+DC00) embedded mid-string, with ordinary text both before and after it, is the string-value counterpart to A3-REJECT-012's key-position case: unpaired, not valid Unicode, no well-formed UTF-8 encoding, and unlike A4-REJECT-013's simpler isolated case this confirms the defect is caught with real surrounding content rather than only in a minimal reproduction. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"name\": \"before\\udc00after\"}" + }, + { + "id": "A4-REJECT-015", + "group": "a4-string-serialization", + "clause": "RFC8785-3.2.2.2", + "disposition": "MUST-REJECT", + "rationale": "A high surrogate (U+D800) followed by an ordinary BMP character (a plain letter, not its matching low surrogate) leaves the high surrogate unpaired for exactly the same reason as A4-REJECT-013/014, just constructed a third way. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"name\": \"\\ud800x\"}" + }, + { + "id": "A5-001", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "The a2aproject/A2A#2122 counterexample verbatim (case C4): 0.000001 must serialize as the literal '0.000001', not as '1e-06' (Python's repr threshold, which is what a2a-python's json.dumps produces and what makes this a real cross-SDK break).", + "input": { + "tolerance": 1e-06 + }, + "canonical_utf8_hex": "7b22746f6c6572616e6365223a302e3030303030317d" + }, + { + "id": "A5-002", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "The a2aproject/A2A#2122 'big' companion value from the same case C4: 1e21 crosses the ECMAScript fixed/exponential boundary on the large-magnitude side and must serialize in exponential form.", + "input": { + "big": 1e+21 + }, + "canonical_utf8_hex": "7b22626967223a31652b32317d" + }, + { + "id": "A5-003", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Positive integers serialize with no decimal point and no fractional zeros.", + "input": { + "n": 42 + }, + "canonical_utf8_hex": "7b226e223a34327d" + }, + { + "id": "A5-004", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Negative integers keep the sign and otherwise follow the same integer rule.", + "input": { + "n": -17 + }, + "canonical_utf8_hex": "7b226e223a2d31377d" + }, + { + "id": "A5-005", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Zero serializes as the bare digit '0'.", + "input": { + "n": 0 + }, + "canonical_utf8_hex": "7b226e223a307d" + }, + { + "id": "A5-006", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Negative zero is a distinct IEEE 754 bit pattern from positive zero, but ECMAScript's Number::toString collapses both to the same string '0' -- there is no '-0' output form.", + "input": { + "n": -0.0 + }, + "canonical_utf8_hex": "7b226e223a307d" + }, + { + "id": "A5-007", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "0.1 cannot be represented exactly in IEEE 754 double precision; ECMAScript's algorithm prints the shortest decimal digit sequence that round-trips back to the same double, not the full ~17-digit exact binary expansion.", + "input": { + "n": 0.1 + }, + "canonical_utf8_hex": "7b226e223a302e317d" + }, + { + "id": "A5-008", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "A five-significant-digit decimal exercises the shortest-round-trip rule on a value with more precision than 0.1.", + "input": { + "n": 3.14159 + }, + "canonical_utf8_hex": "7b226e223a332e31343135397d" + }, + { + "id": "A5-009", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Number.MAX_SAFE_INTEGER (2^53 - 1): the largest integer every JS engine represents exactly, a natural boundary value for integer serialization.", + "input": { + "n": 9007199254740991 + }, + "canonical_utf8_hex": "7b226e223a393030373139393235343734303939317d" + }, + { + "id": "A5-010", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "A negative decimal combines the sign rule (A5-004) with fractional shortest-digit serialization (A5-007) in one value.", + "input": { + "n": -123.456 + }, + "canonical_utf8_hex": "7b226e223a2d3132332e3435367d" + }, + { + "id": "A5-011", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "One order of magnitude below the a2aproject/A2A#2122 case C4 value (A5-001): 0.0000001 (1e-7) probes the small-magnitude side of the fixed/exponential boundary that 0.000001 sits just on the fixed side of.", + "input": { + "n": 1e-07 + }, + "canonical_utf8_hex": "7b226e223a31652d377d" + }, + { + "id": "A5-012", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "One order of magnitude below the C4 'big' value (A5-002): 1e20 probes the large-magnitude side of the same boundary that 1e21 sits just past.", + "input": { + "n": 1e+20 + }, + "canonical_utf8_hex": "7b226e223a3130303030303030303030303030303030303030307d" + }, + { + "id": "A5-013", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "0.1 + 0.2 in IEEE 754 double arithmetic does not equal 0.3; this is the resulting value (0.30000000000000004), a classic case requiring the full shortest-round-trip digit count rather than a short 'looks clean' rounding.", + "input": { + "n": 0.30000000000000004 + }, + "canonical_utf8_hex": "7b226e223a302e33303030303030303030303030303030347d" + }, + { + "id": "A5-014", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "A round integer must not be printed in exponential form just because it has trailing zeros; 100 stays '100', not '1e2'.", + "input": { + "n": 100 + }, + "canonical_utf8_hex": "7b226e223a3130307d" + }, + { + "id": "A5-015", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-ACCEPT", + "rationale": "Input syntax is not preserved verbatim: '1E1' (uppercase E, RFC 8259 permits both cases) and a trailing '.0' both parse to the double values 10 and 1 respectively, and the canonical form is the ECMAScript string for that VALUE, not a copy of the input digit sequence -- this vector's input intentionally does not look like its output.", + "input": { + "exp_upper": 10.0, + "trailing_zero": 1.0 + }, + "canonical_utf8_hex": "7b226578705f7570706572223a31302c22747261696c696e675f7a65726f223a317d" + }, + { + "id": "A5-REJECT-016", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-REJECT", + "rationale": "NaN is not a valid RFC 8259 JSON number and has no ECMAScript Number::toString representation as a JSON token; some lenient parsers accept the bare word NaN as a non-standard extension, but a conformant canonicalizer must refuse to emit it rather than pass the extension through. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"n\": NaN}" + }, + { + "id": "A5-REJECT-017", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-REJECT", + "rationale": "Infinity is the positive-magnitude sibling of A5-REJECT-016: not valid JSON, no canonical representation, must be refused rather than passed through by any parser lenient enough to accept the bare word. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"n\": Infinity}" + }, + { + "id": "A5-REJECT-018", + "group": "a5-number-serialization", + "clause": "RFC8785-3.2.2.3", + "disposition": "MUST-REJECT", + "rationale": "Negative infinity completes the set: same defect as A5-REJECT-017 with the sign flipped, confirming the rejection is not specific to the unsigned spelling. (confirmed refused by both independent oracles: go+py)", + "input_raw": "{\"n\": -Infinity}" + }, + { + "id": "A6-001", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "An empty array is a valid value and serializes as '[]' with no whitespace.", + "input": { + "items": [] + }, + "canonical_utf8_hex": "7b226974656d73223a5b5d7d" + }, + { + "id": "A6-002", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "An empty object is a valid value and serializes as '{}' with no whitespace.", + "input": { + "config": {} + }, + "canonical_utf8_hex": "7b22636f6e666967223a7b7d7d" + }, + { + "id": "A6-003", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "Arrays preserve input order; RFC 8785 orders object keys, never array elements.", + "input": { + "matrix": [ + [ + 1, + 2 + ], + [ + 3, + 4 + ] + ] + }, + "canonical_utf8_hex": "7b226d6174726978223a5b5b312c325d2c5b332c345d5d7d" + }, + { + "id": "A6-004", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "Object nesting recurses the same key-ordering rule at every depth.", + "input": { + "a": { + "b": { + "c": 1 + } + } + }, + "canonical_utf8_hex": "7b2261223a7b2262223a7b2263223a317d7d7d" + }, + { + "id": "A6-005", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "An array may mix every JSON primitive type; each element serializes per its own type's rule and array order is preserved regardless of type.", + "input": { + "mixed": [ + 1, + "two", + true, + false, + null, + 3.5 + ] + }, + "canonical_utf8_hex": "7b226d69786564223a5b312c2274776f222c747275652c66616c73652c6e756c6c2c332e355d7d" + }, + { + "id": "A6-006", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "The null literal serializes as the bare token 'null'.", + "input": { + "value": null + }, + "canonical_utf8_hex": "7b2276616c7565223a6e756c6c7d" + }, + { + "id": "A6-007", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "Boolean literals serialize as the bare tokens 'true'/'false', and sibling keys still sort (RFC8785-3.2.3) regardless of value type.", + "input": { + "flag_true": true, + "flag_false": false + }, + "canonical_utf8_hex": "7b22666c61675f66616c7365223a66616c73652c22666c61675f74727565223a747275657d" + }, + { + "id": "A6-008", + "group": "a6-arrays-nesting-literals", + "clause": "RFC8785-3.2.1", + "disposition": "MUST-ACCEPT", + "rationale": "Deep alternation of arrays containing objects containing arrays exercises the recursion to more than two levels; order is preserved at every array level and keys sorted at every object level.", + "input": { + "deep": [ + { + "x": [ + 1, + { + "y": 2 + } + ] + }, + { + "x": [] + } + ] + }, + "canonical_utf8_hex": "7b2264656570223a5b7b2278223a5b312c7b2279223a327d5d7d2c7b2278223a5b5d7d5d7d" + } + ] +} diff --git a/tests/utils/test_jcs.py b/tests/utils/test_jcs.py new file mode 100644 index 000000000..f897cc542 --- /dev/null +++ b/tests/utils/test_jcs.py @@ -0,0 +1,496 @@ +"""RFC 8785 (JCS) conformance tests for Agent Card canonicalization. + +The vector corpus in `jcs_vectors.json` is language-neutral and was produced by +two independent RFC 8785 implementations written by neither SDK author, so +these tests check conformance against the RFC rather than against this SDK's +own output. +""" + +import json +import math +import random +import struct + +from pathlib import Path +from typing import Any + +import jwt +import pytest +import rfc8785 + +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentExtension, + AgentInterface, + AgentSkill, +) +from a2a.utils import signing +from a2a.utils._jcs import MAX_DEPTH, CanonicalizationError, canonicalize +from google.protobuf import struct_pb2 + + +_VECTORS = json.loads( + (Path(__file__).parent / 'jcs_vectors.json').read_text(encoding='utf-8') +) +_ACCEPT = [v for v in _VECTORS['vectors'] if v['disposition'] == 'MUST-ACCEPT'] +_REJECT = [v for v in _VECTORS['vectors'] if v['disposition'] == 'MUST-REJECT'] + + +def _agent_card( + name: str = 'Agent', + description: str = 'description', + ext_params: dict[str, Any] | None = None, +) -> AgentCard: + """Builds a minimal valid AgentCard, optionally with extension params.""" + capabilities = AgentCapabilities(streaming=True) + if ext_params is not None: + params = struct_pb2.Struct() + params.update(ext_params) + capabilities.extensions.append( + AgentExtension(uri='https://example.com/ext', params=params) + ) + return AgentCard( + name=name, + description=description, + version='1.0.0', + supported_interfaces=[ + AgentInterface( + url='https://example.com/a2a/v1', + protocol_binding='JSONRPC', + protocol_version='1.0', + ) + ], + capabilities=capabilities, + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + skills=[ + AgentSkill( + id='skill', name='skill', description='skill', tags=['tag'] + ) + ], + ) + + +def test_vector_corpus_is_complete(): + """The corpus must be whole; a partially loaded corpus passes vacuously.""" + assert len(_ACCEPT) == _VECTORS['counts']['accept'] == 47 + assert len(_REJECT) == _VECTORS['counts']['reject'] == 10 + assert len(_VECTORS['vectors']) == _VECTORS['counts']['total'] == 57 + + +@pytest.mark.parametrize('vector', _ACCEPT, ids=lambda v: v['id']) +def test_vector_must_accept(vector): + """Canonical output must match the corpus byte for byte.""" + value = vector['input'] + if vector['group'] == 'a2-signatures-exclusion': + # Spec 8.4.1 rule 3: the signatures key is excluded unconditionally. + # This mirrors the two steps _canonicalize_agent_card applies to the + # card dict before serializing it. + value = dict(value) + value.pop('signatures', None) + value = signing._clean_empty(value) + + actual = canonicalize(value).encode('utf-8') + assert actual.hex() == vector['canonical_utf8_hex'], vector['rationale'] + + +@pytest.mark.parametrize('vector', _REJECT, ids=lambda v: v['id']) +def test_vector_must_reject(vector): + """Values with no canonical form must be refused, not silently mangled.""" + if vector['group'] == 'a2-signatures-exclusion': + # These two vectors reject claimed-canonical output that still carries + # a signatures key, which is a property of the producer rather than an + # input it can be handed. The producer side is asserted directly. + card = _agent_card() + card.signatures.append( + signing.AgentCardSignature(protected='abc', signature='def') + ) + assert 'signatures' not in json.loads( + signing._canonicalize_agent_card(card) + ) + return + + value = json.loads(vector['input_raw']) + with pytest.raises(CanonicalizationError): + canonicalize(value) + + +@pytest.mark.parametrize('vector', _ACCEPT, ids=lambda v: v['id']) +def test_vector_agrees_with_independent_implementation(vector): + """An independent RFC 8785 implementation must produce the same bytes. + + The corpus and the oracle are separate sources of truth: the corpus could + be transcribed wrongly, and the oracle could be wrong about a clause the + corpus covers. Requiring both makes a single mistake visible. + """ + value = vector['input'] + if vector['group'] == 'a2-signatures-exclusion': + value = dict(value) + value.pop('signatures', None) + value = signing._clean_empty(value) + assert canonicalize(value).encode('utf-8') == rfc8785.dumps(value) + + +# --- Axis 1: literal UTF-8 rather than \uXXXX escapes (RFC 8785 3.2.2.2) --- + + +def test_non_ascii_is_emitted_as_literal_utf8(): + """json.dumps defaults to ensure_ascii=True; RFC 8785 forbids the escape.""" + canonical = signing._canonicalize_agent_card( + _agent_card(name='Café Agent', description='Planifie des itinéraires.') + ) + assert 'Café Agent' in canonical + assert '\\u00e9' not in canonical + + +def test_line_and_paragraph_separators_are_not_escaped(): + """U+2028 and U+2029 are escaped in JavaScript source, not in JCS.""" + canonical = signing._canonicalize_agent_card(_agent_card(name='a
b
c')) + assert 'a
b
c' in canonical + assert '\\u2028' not in canonical + assert '\\u2029' not in canonical + + +def test_mandatory_escapes_are_still_applied(): + """The C0 range, quote and backslash stay escaped (RFC 8785 3.2.2.2).""" + assert canonicalize({'k': 'a"b\\c\nd\te\x00f\x1ff'}) == ( + '{"k":"a\\"b\\\\c\\nd\\te\\u0000f\\u001ff"}' + ) + + +def test_non_bmp_characters_survive_as_literal_utf8(): + """Astral characters are one UTF-8 sequence, not two escaped surrogates.""" + canonical = canonicalize({'k': '\U0001f600'}) + assert canonical == '{"k":"\U0001f600"}' + assert canonical.encode('utf-8') == b'{"k":"\xf0\x9f\x98\x80"}' + + +# --- Axis 2: UTF-16 code unit key ordering (RFC 8785 3.2.3) --- + + +def test_keys_sort_by_utf16_code_unit_not_code_point(): + """The two orders disagree whenever a non-BMP key meets a key >= U+E000. + + U+1F600's leading surrogate is 0xD83D, which sorts below U+FF01, while its + code point U+1F600 sorts above it. sort_keys=True gets this backwards. + """ + canonical = canonicalize({'\U0001f600': 1, '!': 2}) + assert canonical == '{"\U0001f600":1,"!":2}' + assert json.dumps( + {'\U0001f600': 1, '!': 2}, sort_keys=True, ensure_ascii=False + ) != canonical.replace(':', ': ').replace(',', ', ') + # The decisive assertion: our first key is the astral one. + assert list(json.loads(canonical))[0] == '\U0001f600' + + +def test_key_ordering_reaches_the_canonicalizer_through_extension_params(): + """Arbitrary keys are attacker-reachable via AgentExtension.params.""" + canonical = signing._canonicalize_agent_card( + _agent_card(ext_params={'\U0001f600': 1, '!': 2}) + ) + params = canonical[canonical.index('"params"') :] + assert params.index('\U0001f600') < params.index('!') + + +def test_keys_sort_by_code_unit_across_the_bmp_boundary(): + """A shorter key that is a prefix of a longer one sorts first.""" + assert canonicalize({'ab': 1, 'a': 2, 'b': 3}) == '{"a":2,"ab":1,"b":3}' + + +def test_empty_key_sorts_first(): + assert canonicalize({'a': 1, '': 2}) == '{"":2,"a":1}' + + +# --- Axis 3: ECMAScript number formatting (RFC 8785 3.2.2.3) --- + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + (0.000001, '0.000001'), + (1e-7, '1e-7'), + (1e-6, '0.000001'), + (0.0, '0'), + (-0.0, '0'), + (1.0, '1'), + (-1.0, '-1'), + (1e20, '100000000000000000000'), + (1e21, '1e+21'), + (1.2e21, '1.2e+21'), + (5e-324, '5e-324'), + (2.2250738585072014e-308, '2.2250738585072014e-308'), + (9007199254740991.0, '9007199254740991'), + (0.1, '0.1'), + (1.5, '1.5'), + (1e100, '1e+100'), + ], +) +def test_number_formatting(value, expected): + """repr and Number::toString disagree on all of these.""" + assert canonicalize({'n': value}) == f'{{"n":{expected}}}' + + +def test_number_formatting_matches_the_oracle_over_random_doubles(): + """Sweep the double bit space, not just the cases someone thought of. + + ECMAScript number formatting is the expensive half of RFC 8785 and the + half a hand-written table cannot cover: the divergences live at the + exponential-notation thresholds, in the denormal range and wherever the + shortest round-tripping representation changes length. The seed is fixed + so any failure names one reproducible double. + """ + rng = random.Random(8785) + compared = 0 + for _ in range(20000): + (value,) = struct.unpack(' 15000 + + +def test_number_formatting_matches_the_oracle_at_the_exponent_thresholds(): + """The 1e21 and 1e-7 thresholds are where repr and Number::toString part.""" + values = [] + for exponent in range(-330, 309): + for mantissa in ( + '1', + '1.5', + '9', + '9.999999999999998', + '1.0000000000000002', + '5', + '3', + ): + value = float(f'{mantissa}e{exponent}') + if math.isfinite(value): + values.append(value) + assert len(values) > 4000 + for value in values: + assert canonicalize([value]) == rfc8785.dumps([value]).decode(), value + + +def test_number_formatting_matches_independent_implementation(): + """Cross-check every number case against the oracle, not just our table.""" + values = [ + 0.000001, 1e-7, 1e-6, 0.0, -0.0, 1.0, -1.0, 1e20, 1e21, 1.2e21, + 5e-324, 2.2250738585072014e-308, 9007199254740991.0, 0.1, 1.5, 1e100, + -1e-7, 3.141592653589793, 1e-323, 1.7976931348623157e308, + ] # fmt: skip + for value in values: + assert ( + canonicalize({'n': value}) == rfc8785.dumps({'n': value}).decode() + ) + + +def test_integral_doubles_lose_their_trailing_zero(): + """protobuf Struct stores every number as a double, so this is the common case. + + A card declaring an integer extension param currently signs "1.0"; RFC 8785 + requires "1", which is what every other implementation produces. + """ + canonical = signing._canonicalize_agent_card( + _agent_card(ext_params={'count': 1}) + ) + assert '"count":1}' in canonical + # Anchored on the key: "1.0" also occurs inside the version string. + assert '"count":1.0' not in canonical + + +def test_booleans_are_not_treated_as_integers(): + """bool subclasses int, so an unguarded isinstance check emits 1 and 0.""" + assert canonicalize({'a': True, 'b': False}) == '{"a":true,"b":false}' + + +@pytest.mark.parametrize('value', [float('nan'), float('inf'), float('-inf')]) +def test_non_finite_numbers_are_rejected(value): + with pytest.raises(CanonicalizationError): + canonicalize({'n': value}) + + +@pytest.mark.parametrize('value', [2**53, -(2**53), 2**63, 10**30]) +def test_integers_outside_the_double_range_are_rejected(value): + """Beyond 2**53-1 an integer has no exact JSON number, so no canonical form.""" + with pytest.raises(CanonicalizationError): + canonicalize({'n': value}) + + +@pytest.mark.parametrize('value', [2**53 - 1, -(2**53) + 1, 0, -1, 42]) +def test_integers_inside_the_double_range_are_accepted(value): + assert canonicalize({'n': value}) == f'{{"n":{value}}}' + + +# --- Depth bound --- + + +def _nest(depth: int) -> dict[str, Any]: + value: Any = {'x': 1} + for _ in range(depth): + value = {'a': value} + return value + + +def test_nesting_at_the_limit_is_accepted(): + """Sit on the boundary, not near it. + + _nest(n) builds n wrappers around a leaf object, so the deepest container + is at depth n + 1. MAX_DEPTH - 1 wrappers is therefore the last accepted + shape and MAX_DEPTH wrappers is the first rejected one; asserting both + pins the limit rather than merely staying below it. + """ + canonicalize(_nest(MAX_DEPTH - 1)) + with pytest.raises(CanonicalizationError): + canonicalize(_nest(MAX_DEPTH)) + + +@pytest.mark.parametrize('depth', [MAX_DEPTH + 1, 5000]) +def test_nesting_beyond_the_limit_is_rejected(depth): + """Unbounded recursion here is a crash in whoever verifies the card.""" + with pytest.raises(CanonicalizationError): + canonicalize(_nest(depth)) + + +@pytest.mark.parametrize('depth', [MAX_DEPTH + 1, 5000]) +def test_clean_empty_is_bounded_too(depth): + """_clean_empty runs first, so an uncapped one makes the cap unreachable.""" + with pytest.raises(CanonicalizationError): + signing._clean_empty(_nest(depth)) + + +def test_deep_arrays_are_bounded(): + value: Any = [1] + for _ in range(5000): + value = [value] + with pytest.raises(CanonicalizationError): + canonicalize(value) + + +# --- Types with no canonical form --- + + +@pytest.mark.parametrize( + 'value', + ['\ud800', 'a\udc00b', '\ud800\ud800'], +) +def test_unpaired_surrogates_in_values_are_rejected(value): + with pytest.raises(CanonicalizationError): + canonicalize({'k': value}) + + +@pytest.mark.parametrize('key', ['\ud800', 'b\udc00key']) +def test_unpaired_surrogates_in_keys_are_rejected(key): + with pytest.raises(CanonicalizationError): + canonicalize({key: 1}) + + +def test_non_string_keys_are_rejected(): + with pytest.raises(CanonicalizationError): + canonicalize({1: 'a'}) + + +def test_unsupported_types_are_rejected(): + with pytest.raises(CanonicalizationError): + canonicalize({'k': {1, 2}}) + + +def test_noncharacters_are_permitted(): + """U+FFFE and U+FFFF are valid Unicode; JCS has no rule excluding them.""" + assert canonicalize({'k': '￾￿'}) == '{"k":"￾￿"}' + + +# --- End to end through the signing API --- + + +def test_signer_and_verifier_agree_on_a_non_ascii_card(): + """The bytes signed must be the canonical bytes, not a re-serialization. + + Handing a parsed dict to the JWT layer lets PyJWT re-encode it with + ensure_ascii=True, which passes for an ASCII card and fails for every other + card. The ASCII case is the control. + """ + key = 'a-shared-secret-of-sufficient-length-for-hs256' + signer = signing.create_agent_card_signer(key, {'kid': 'k', 'alg': 'HS256'}) + verifier = signing.create_signature_verifier( + lambda kid, jku: key, ['HS256'] + ) + for name in ('Agent', 'Café Agent', '🤖 Agent', 'a
b'): + verifier(signer(_agent_card(name=name))) + + +def test_canonical_form_matches_the_reference_for_a_non_ascii_card(): + canonical = signing._canonicalize_agent_card( + _agent_card(name='Café Agent', description='Planifie des itinéraires.') + ) + assert canonical.encode('utf-8') == rfc8785.dumps(json.loads(canonical)) + + +def test_uncanonicalizable_card_fails_as_a_signature_error(): + """A hostile card must not raise a new exception type at callers.""" + key = 'a-shared-secret-of-sufficient-length-for-hs256' + card = _agent_card() + card.signatures.append( + signing.AgentCardSignature(protected='abc', signature='def') + ) + verifier = signing.create_signature_verifier( + lambda kid, jku: key, ['HS256'] + ) + deep: Any = {'x': 1} + for _ in range(MAX_DEPTH + 5): + deep = {'a': deep} + params = struct_pb2.Struct() + # Built directly rather than through the proto parser, whose own recursion + # limit is lower than MAX_DEPTH and would refuse this first. + _fill_struct(params, deep) + card.capabilities.extensions.append( + AgentExtension(uri='https://example.com/ext') + ) + card.capabilities.extensions[0].params.CopyFrom(params) + with pytest.raises(signing.InvalidSignaturesError): + verifier(card) + + +def _fill_struct(struct: struct_pb2.Struct, value: dict[str, Any]) -> None: + """Builds a nested Struct without going through the proto parser.""" + for key, item in value.items(): + if isinstance(item, dict): + _fill_struct(struct.fields[key].struct_value, item) + else: + struct.fields[key].number_value = item + + +def test_protected_header_is_unchanged_by_the_detached_payload_encoding(): + """Signing the canonical bytes must not disturb the protected header. + + The signer hands pre-serialized bytes to the JWS layer so that PyJWT + cannot re-serialize the payload with ensure_ascii=True. That swap would be + a silent compatibility break if it also changed the header, since the + header is what a verifier reads kid and alg out of. + """ + key = 'a-shared-secret-of-sufficient-length-for-hs256' + header = {'kid': 'k', 'alg': 'HS256'} + signed = signing.create_agent_card_signer(key, header)(_agent_card()) + reference = jwt.encode( + payload={'a': 1}, key=key, algorithm='HS256', headers=dict(header) + ).split('.')[0] + assert signed.signatures[0].protected == reference + + +def test_non_finite_numbers_cannot_reach_the_canonicalizer_through_a_card(): + """protobuf refuses NaN in a Struct, so the guard in _format_number is depth. + + Recording where the first refusal happens keeps a later protobuf change + from quietly moving the only rejection of NaN out of the stack. + """ + params = struct_pb2.Struct() + params.fields['x'].number_value = float('nan') + card = _agent_card() + card.capabilities.extensions.append( + AgentExtension(uri='https://example.com/ext') + ) + card.capabilities.extensions[0].params.CopyFrom(params) + with pytest.raises(Exception) as excinfo: + signing._canonicalize_agent_card(card) + assert 'NaN' in str(excinfo.value) From 4b8456564431a88d94333aeda91e4dd4ac24ee22 Mon Sep 17 00:00:00 2001 From: Sankalp Gilda Date: Wed, 19 Aug 2026 22:37:39 -0400 Subject: [PATCH 3/3] docs(signing): reword a term the spell check does not recognize 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 --- src/a2a/utils/_jcs.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/a2a/utils/_jcs.py b/src/a2a/utils/_jcs.py index ce10765c7..96f185ac7 100644 --- a/src/a2a/utils/_jcs.py +++ b/src/a2a/utils/_jcs.py @@ -148,10 +148,11 @@ def _format_scalar(obj: Any) -> str: def _sorted_items(obj: dict[Any, Any]) -> list[tuple[str, Any]]: """Sorts a dict's items by UTF-16 code unit, per RFC 8785 section 3.2.3. - Comparing big-endian UTF-16 encodings bytewise is equivalent to comparing - the code unit sequences numerically, which is what the RFC requires and - what `sorted(..., key=str)` does not do: code point order and code unit - order disagree for every key containing a character above the BMP. + Comparing big-endian UTF-16 encodings byte by byte is equivalent to + comparing the code unit sequences numerically, which is what the RFC + requires and what `sorted(..., key=str)` does not do: code point order + and code unit order disagree for every key containing a character above + the BMP. """ try: return sorted(obj.items(), key=lambda kv: kv[0].encode('utf-16-be'))