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..96f185ac7 --- /dev/null +++ b/src/a2a/utils/_jcs.py @@ -0,0 +1,228 @@ +"""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 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')) + 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/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) 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"