Skip to content

Commit 1a973f0

Browse files
committed
feat: Add Agent Card Signature support
1 parent 3bfbea9 commit 1a973f0

File tree

12 files changed

+800
-11
lines changed

12 files changed

+800
-11
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ 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"]
3838
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
39+
signing = ["python-jose>=3.0.0"]
3940

4041
sql = ["a2a-sdk[postgresql,mysql,sqlite]"]
4142

@@ -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]

src/a2a/client/base_client.py

Lines changed: 7 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,16 @@ async def get_card(
270271
Args:
271272
context: The client call context.
272273
extensions: List of extensions to be activated.
274+
key_provider: A callable that takes key-id (kid) and JSON web key url (jku)
275+
and returns the verification key for signature verification.
273276
274277
Returns:
275278
The `AgentCard` for the agent.
276279
"""
277280
card = await self._transport.get_card(
278-
context=context, extensions=extensions
281+
context=context,
282+
extensions=extensions,
283+
signature_verifier=signature_verifier,
279284
)
280285
self._card = card
281286
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

@@ -376,16 +376,20 @@ async def get_card(
376376
*,
377377
context: ClientCallContext | None = None,
378378
extensions: list[str] | None = None,
379+
signature_verifier: Callable[[AgentCard], None] | None = None,
379380
) -> AgentCard:
380381
"""Retrieves the agent's card."""
381382
modified_kwargs = update_extension_header(
382383
self._get_http_args(context),
383384
extensions if extensions is not None else self.extensions,
384385
)
385386
card = self.agent_card
387+
386388
if not card:
387389
resolver = A2ACardResolver(self.httpx_client, self.url)
388390
card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
391+
if signature_verifier is not None:
392+
signature_verifier(card)
389393
self._needs_extended_card = (
390394
card.supports_authenticated_extended_card
391395
)
@@ -410,9 +414,13 @@ async def get_card(
410414
)
411415
if isinstance(response.root, JSONRPCErrorResponse):
412416
raise A2AClientJSONRPCError(response.root)
413-
self.agent_card = response.root.result
417+
card = response.root.result
418+
if signature_verifier is not None:
419+
signature_verifier(card)
420+
421+
self.agent_card = card
414422
self._needs_extended_card = False
415-
return self.agent_card
423+
return card
416424

417425
async def close(self) -> None:
418426
"""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
@@ -368,16 +368,20 @@ async def get_card(
368368
*,
369369
context: ClientCallContext | None = None,
370370
extensions: list[str] | None = None,
371+
signature_verifier: Callable[[AgentCard], None] | None = None,
371372
) -> AgentCard:
372373
"""Retrieves the agent's card."""
373374
modified_kwargs = update_extension_header(
374375
self._get_http_args(context),
375376
extensions if extensions is not None else self.extensions,
376377
)
377378
card = self.agent_card
379+
378380
if not card:
379381
resolver = A2ACardResolver(self.httpx_client, self.url)
380382
card = await resolver.get_agent_card(http_kwargs=modified_kwargs)
383+
if signature_verifier is not None:
384+
signature_verifier(card)
381385
self._needs_extended_card = (
382386
card.supports_authenticated_extended_card
383387
)
@@ -395,6 +399,9 @@ async def get_card(
395399
'/v1/card', {}, modified_kwargs
396400
)
397401
card = AgentCard.model_validate(response_data)
402+
if signature_verifier is not None:
403+
signature_verifier(card)
404+
398405
self.agent_card = card
399406
self._needs_extended_card = False
400407
return card

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

src/a2a/utils/signing.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import json
2+
3+
from collections.abc import Callable
4+
from typing import Any
5+
6+
7+
try:
8+
from jose import jws
9+
from jose.backends.base import Key
10+
from jose.exceptions import JOSEError
11+
from jose.utils import base64url_decode, base64url_encode
12+
except ImportError as e:
13+
raise ImportError(
14+
'A2AUtilsSigning requires python-jose to be installed. '
15+
'Install with: '
16+
"'pip install a2a-sdk[signing]'"
17+
) from e
18+
19+
from a2a.types import AgentCard, AgentCardSignature
20+
21+
22+
def clean_empty(d: Any) -> Any:
23+
"""Recursively remove empty lists, dicts, strings, and None values from a dictionary."""
24+
if isinstance(d, dict):
25+
cleaned = {k: clean_empty(v) for k, v in d.items()}
26+
return {
27+
k: v
28+
for k, v in cleaned.items()
29+
if v is not None and (isinstance(v, (bool, int, float)) or v)
30+
}
31+
if isinstance(d, list):
32+
cleaned = [clean_empty(v) for v in d]
33+
return [
34+
v
35+
for v in cleaned
36+
if v is not None and (isinstance(v, (bool, int, float)) or v)
37+
]
38+
return d if d not in [None, '', [], {}] else None
39+
40+
41+
def canonicalize_agent_card(agent_card: AgentCard) -> str:
42+
"""Canonicalizes the Agent Card JSON according to RFC 8785 (JCS)."""
43+
card_dict = agent_card.model_dump(
44+
exclude={'signatures'},
45+
exclude_defaults=True,
46+
by_alias=True,
47+
)
48+
# Ensure 'protocol_version' is always included
49+
protocol_version_alias = (
50+
AgentCard.model_fields['protocol_version'].alias or 'protocol_version'
51+
)
52+
if protocol_version_alias not in card_dict:
53+
card_dict[protocol_version_alias] = agent_card.protocol_version
54+
55+
# Recursively remove empty/None values
56+
cleaned_dict = clean_empty(card_dict)
57+
58+
return json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True)
59+
60+
61+
def create_agent_card_signer(
62+
signing_key: str | bytes | dict[str, Any] | Key,
63+
kid: str,
64+
alg: str = 'HS256',
65+
jku: str | None = None,
66+
) -> Callable[[AgentCard], AgentCard]:
67+
"""Creates a function that signs an AgentCard and adds the signature.
68+
69+
Args:
70+
signing_key: The private key for signing.
71+
kid: Key ID for the signing key.
72+
alg: The algorithm to use (e.g., "ES256", "RS256").
73+
jku: Optional URL to the JWKS.
74+
75+
Returns:
76+
A callable that takes an AgentCard and returns the modified AgentCard with a signature.
77+
"""
78+
79+
def agent_card_signer(agent_card: AgentCard) -> AgentCard:
80+
"""The actual card_modifier function."""
81+
canonical_payload = canonicalize_agent_card(agent_card)
82+
83+
headers = {'kid': kid, 'typ': 'JOSE'}
84+
if jku:
85+
headers['jku'] = jku
86+
87+
jws_string = jws.sign(
88+
payload=canonical_payload.encode('utf-8'),
89+
key=signing_key,
90+
headers=headers,
91+
algorithm=alg,
92+
)
93+
94+
# The result of jws.sign is a compact serialization: HEADER.PAYLOAD.SIGNATURE
95+
protected_header, _, signature = jws_string.split('.')
96+
97+
agent_card_signature = AgentCardSignature(
98+
protected=protected_header,
99+
signature=signature,
100+
)
101+
102+
agent_card.signatures = (agent_card.signatures or []) + [
103+
agent_card_signature
104+
]
105+
return agent_card
106+
107+
return agent_card_signer
108+
109+
110+
def create_signature_verifier(
111+
key_provider: Callable[
112+
[str | None, str | None], str | bytes | dict[str, Any] | Key
113+
],
114+
) -> Callable[[AgentCard], None]:
115+
"""Creates a function that verifies AgentCard signatures.
116+
117+
Args:
118+
key_provider: A callable that takes key-id (kid) and JSON web key url (jku) and returns the verification key.
119+
120+
Returns:
121+
A callable that takes an AgentCard, and raises an error if none of the signatures are valid.
122+
"""
123+
124+
def signature_verifier(
125+
agent_card: AgentCard,
126+
) -> None:
127+
"""The actual signature_verifier function."""
128+
if not agent_card.signatures:
129+
raise JOSEError('No signatures found on AgentCard')
130+
131+
last_error = None
132+
for agent_card_signature in agent_card.signatures:
133+
try:
134+
# fetch kid and jku from protected header
135+
protected_header_json = base64url_decode(
136+
agent_card_signature.protected.encode('utf-8')
137+
).decode('utf-8')
138+
protected_header = json.loads(protected_header_json)
139+
kid = protected_header.get('kid')
140+
jku = protected_header.get('jku')
141+
verification_key = key_provider(kid, jku)
142+
143+
canonical_payload = canonicalize_agent_card(agent_card)
144+
encoded_payload = base64url_encode(
145+
canonical_payload.encode('utf-8')
146+
).decode('utf-8')
147+
token = f'{agent_card_signature.protected}.{encoded_payload}.{agent_card_signature.signature}'
148+
149+
jws.verify(
150+
token=token,
151+
key=verification_key,
152+
algorithms=None,
153+
)
154+
return # Found a valid signature
155+
156+
except JOSEError as e:
157+
last_error = e
158+
continue
159+
raise JOSEError('No valid signature found') from last_error
160+
161+
return signature_verifier

0 commit comments

Comments
 (0)