Skip to content

Commit 8a76730

Browse files
authored
feat: Implement Agent Card Signing and Verification per Spec (#581)
This PR introduces digital signatures for Agent Cards to ensure authenticity and integrity, adhering to the A2A specification for [Agent Card Signing (Section 8.4).](https://a2a-protocol.org/latest/specification/#84-agent-card-signing) ## Changes: - Implement `Canonicalization` Logic (`src/a2a/utils/signing.py`) - Add `Signing` and `Verification` Utilities (`src/a2a/utils/signing.py`): - `create_agent_card_signer` which generates an `agent_card_signer` for signing `AgentCards` - `create_signature_verifier` which generates a `signature_verifier` for verification of `AgentCard` signatures - Enable signature verification support for `json-rpc`, `rest` and `gRPC` transports - Add Protobuf Conversion for Signatures (`src/a2a/utils/proto_utils.py`) ensuring `AgentCardSignature` can be serialized and deserialized for gRPC transport - Add related tests: - integration tests for fetching signed cards from the Server - unit tests for signing util - unit tests for protobuf conversions - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - Important Prefixes for [release-please](https://github.com/googleapis/release-please): - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. - `feat:` represents a new feature, and correlates to a SemVer minor. - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Release-As: 0.3.21
1 parent 5fea21f commit 8a76730

File tree

15 files changed

+954
-11
lines changed

15 files changed

+954
-11
lines changed

.github/actions/spelling/allow.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ initdb
4747
inmemory
4848
INR
4949
isready
50+
jku
5051
JPY
5152
JSONRPCt
53+
jwk
54+
jwks
5255
JWS
56+
jws
57+
kid
5358
kwarg
5459
langgraph
5560
lifecycles

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio_reflection>=1.7.0"]
3535
telemetry = ["opentelemetry-api>=1.33.0", "opentelemetry-sdk>=1.33.0"]
3636
postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
3737
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
38+
signing = ["PyJWT>=2.0.0"]
3839
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
3940

4041
sql = ["a2a-sdk[postgresql,mysql,sqlite]"]
@@ -45,6 +46,7 @@ all = [
4546
"a2a-sdk[encryption]",
4647
"a2a-sdk[grpc]",
4748
"a2a-sdk[telemetry]",
49+
"a2a-sdk[signing]",
4850
]
4951

5052
[project.urls]
@@ -86,6 +88,7 @@ style = "pep440"
8688
dev = [
8789
"datamodel-code-generator>=0.30.0",
8890
"mypy>=1.15.0",
91+
"PyJWT>=2.0.0",
8992
"pytest>=8.3.5",
9093
"pytest-asyncio>=0.26.0",
9194
"pytest-cov>=6.1.1",

src/a2a/client/base_client.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from collections.abc import AsyncIterator
1+
from collections.abc import AsyncIterator, Callable
22
from typing import Any
33

44
from a2a.client.client import (
@@ -261,6 +261,7 @@ async def get_card(
261261
*,
262262
context: ClientCallContext | None = None,
263263
extensions: list[str] | None = None,
264+
signature_verifier: Callable[[AgentCard], None] | None = None,
264265
) -> AgentCard:
265266
"""Retrieves the agent's card.
266267
@@ -270,12 +271,15 @@ async def get_card(
270271
Args:
271272
context: The client call context.
272273
extensions: List of extensions to be activated.
274+
signature_verifier: A callable used to verify the agent card's signatures.
273275
274276
Returns:
275277
The `AgentCard` for the agent.
276278
"""
277279
card = await self._transport.get_card(
278-
context=context, extensions=extensions
280+
context=context,
281+
extensions=extensions,
282+
signature_verifier=signature_verifier,
279283
)
280284
self._card = card
281285
return card

src/a2a/client/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ async def get_card(
185185
*,
186186
context: ClientCallContext | None = None,
187187
extensions: list[str] | None = None,
188+
signature_verifier: Callable[[AgentCard], None] | None = None,
188189
) -> AgentCard:
189190
"""Retrieves the agent's card."""
190191

src/a2a/client/transports/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import ABC, abstractmethod
2-
from collections.abc import AsyncGenerator
2+
from collections.abc import AsyncGenerator, Callable
33

44
from a2a.client.middleware import ClientCallContext
55
from a2a.types import (
@@ -103,6 +103,7 @@ async def get_card(
103103
*,
104104
context: ClientCallContext | None = None,
105105
extensions: list[str] | None = None,
106+
signature_verifier: Callable[[AgentCard], None] | None = None,
106107
) -> AgentCard:
107108
"""Retrieves the AgentCard."""
108109

src/a2a/client/transports/grpc.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from collections.abc import AsyncGenerator
3+
from collections.abc import AsyncGenerator, Callable
44

55

66
try:
@@ -223,6 +223,7 @@ async def get_card(
223223
*,
224224
context: ClientCallContext | None = None,
225225
extensions: list[str] | None = None,
226+
signature_verifier: Callable[[AgentCard], None] | None = None,
226227
) -> AgentCard:
227228
"""Retrieves the agent's card."""
228229
card = self.agent_card
@@ -236,6 +237,9 @@ async def get_card(
236237
metadata=self._get_grpc_metadata(extensions),
237238
)
238239
card = proto_utils.FromProto.agent_card(card_pb)
240+
if signature_verifier is not None:
241+
signature_verifier(card)
242+
239243
self.agent_card = card
240244
self._needs_extended_card = False
241245
return card

src/a2a/client/transports/jsonrpc.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
33

4-
from collections.abc import AsyncGenerator
4+
from collections.abc import AsyncGenerator, Callable
55
from typing import Any
66
from uuid import uuid4
77

@@ -379,16 +379,20 @@ async def get_card(
379379
*,
380380
context: ClientCallContext | None = None,
381381
extensions: list[str] | None = None,
382+
signature_verifier: Callable[[AgentCard], None] | None = None,
382383
) -> AgentCard:
383384
"""Retrieves the agent's card."""
384385
modified_kwargs = update_extension_header(
385386
self._get_http_args(context),
386387
extensions if extensions is not None else self.extensions,
387388
)
388389
card = self.agent_card
390+
389391
if not card:
390392
resolver = A2ACardResolver(self.httpx_client, self.url)
391393
card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
394+
if signature_verifier is not None:
395+
signature_verifier(card)
392396
self._needs_extended_card = (
393397
card.supports_authenticated_extended_card
394398
)
@@ -413,9 +417,13 @@ async def get_card(
413417
)
414418
if isinstance(response.root, JSONRPCErrorResponse):
415419
raise A2AClientJSONRPCError(response.root)
416-
self.agent_card = response.root.result
420+
card = response.root.result
421+
if signature_verifier is not None:
422+
signature_verifier(card)
423+
424+
self.agent_card = card
417425
self._needs_extended_card = False
418-
return self.agent_card
426+
return card
419427

420428
async def close(self) -> None:
421429
"""Closes the httpx client."""

src/a2a/client/transports/rest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
33

4-
from collections.abc import AsyncGenerator
4+
from collections.abc import AsyncGenerator, Callable
55
from typing import Any
66

77
import httpx
@@ -371,16 +371,20 @@ async def get_card(
371371
*,
372372
context: ClientCallContext | None = None,
373373
extensions: list[str] | None = None,
374+
signature_verifier: Callable[[AgentCard], None] | None = None,
374375
) -> AgentCard:
375376
"""Retrieves the agent's card."""
376377
modified_kwargs = update_extension_header(
377378
self._get_http_args(context),
378379
extensions if extensions is not None else self.extensions,
379380
)
380381
card = self.agent_card
382+
381383
if not card:
382384
resolver = A2ACardResolver(self.httpx_client, self.url)
383385
card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
386+
if signature_verifier is not None:
387+
signature_verifier(card)
384388
self._needs_extended_card = (
385389
card.supports_authenticated_extended_card
386390
)
@@ -398,6 +402,9 @@ async def get_card(
398402
'/v1/card', {}, modified_kwargs
399403
)
400404
card = AgentCard.model_validate(response_data)
405+
if signature_verifier is not None:
406+
signature_verifier(card)
407+
401408
self.agent_card = card
402409
self._needs_extended_card = False
403410
return card

src/a2a/utils/helpers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import functools
44
import inspect
5+
import json
56
import logging
67

78
from collections.abc import Callable
89
from typing import Any
910
from uuid import uuid4
1011

1112
from a2a.types import (
13+
AgentCard,
1214
Artifact,
1315
MessageSendParams,
1416
Part,
@@ -340,3 +342,29 @@ def are_modalities_compatible(
340342
return True
341343

342344
return any(x in server_output_modes for x in client_output_modes)
345+
346+
347+
def _clean_empty(d: Any) -> Any:
348+
"""Recursively remove empty strings, lists and dicts from a dictionary."""
349+
if isinstance(d, dict):
350+
cleaned_dict: dict[Any, Any] = {
351+
k: _clean_empty(v) for k, v in d.items()
352+
}
353+
return {k: v for k, v in cleaned_dict.items() if v}
354+
if isinstance(d, list):
355+
cleaned_list: list[Any] = [_clean_empty(v) for v in d]
356+
return [v for v in cleaned_list if v]
357+
return d if d not in ['', [], {}] else None
358+
359+
360+
def canonicalize_agent_card(agent_card: AgentCard) -> str:
361+
"""Canonicalizes the Agent Card JSON according to RFC 8785 (JCS)."""
362+
card_dict = agent_card.model_dump(
363+
exclude={'signatures'},
364+
exclude_defaults=True,
365+
exclude_none=True,
366+
by_alias=True,
367+
)
368+
# Recursively remove empty values
369+
cleaned_dict = _clean_empty(card_dict)
370+
return json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True)

src/a2a/utils/proto_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,21 @@ def agent_card(
397397
]
398398
if card.additional_interfaces
399399
else None,
400+
signatures=[cls.agent_card_signature(x) for x in card.signatures]
401+
if card.signatures
402+
else None,
403+
)
404+
405+
@classmethod
406+
def agent_card_signature(
407+
cls, signature: types.AgentCardSignature
408+
) -> a2a_pb2.AgentCardSignature:
409+
return a2a_pb2.AgentCardSignature(
410+
protected=signature.protected,
411+
signature=signature.signature,
412+
header=dict_to_struct(signature.header)
413+
if signature.header is not None
414+
else None,
400415
)
401416

402417
@classmethod
@@ -865,6 +880,19 @@ def agent_card(
865880
]
866881
if card.additional_interfaces
867882
else None,
883+
signatures=[cls.agent_card_signature(x) for x in card.signatures]
884+
if card.signatures
885+
else None,
886+
)
887+
888+
@classmethod
889+
def agent_card_signature(
890+
cls, signature: a2a_pb2.AgentCardSignature
891+
) -> types.AgentCardSignature:
892+
return types.AgentCardSignature(
893+
protected=signature.protected,
894+
signature=signature.signature,
895+
header=json_format.MessageToDict(signature.header),
868896
)
869897

870898
@classmethod

0 commit comments

Comments
 (0)