Skip to content

Commit 257e4dd

Browse files
committed
fix: make changes based on review comments.
- Move `canonicalize_agent_card` from `signing` to `helpers' - Add `PyJWT` liberary to dev in pyproject.toml - Remove `last_error` from `signature_verifier` - Update tests
1 parent 701eb39 commit 257e4dd

File tree

5 files changed

+102
-77
lines changed

5 files changed

+102
-77
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ 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-
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
3938
signing = ["PyJWT>=2.0.0"]
39+
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
4040

4141
sql = ["a2a-sdk[postgresql,mysql,sqlite]"]
4242

@@ -88,6 +88,7 @@ style = "pep440"
8888
dev = [
8989
"datamodel-code-generator>=0.30.0",
9090
"mypy>=1.15.0",
91+
"PyJWT>=2.0.0"
9192
"pytest>=8.3.5",
9293
"pytest-asyncio>=0.26.0",
9394
"pytest-cov>=6.1.1",

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/signing.py

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from collections.abc import Callable
44
from typing import Any, TypedDict
55

6+
from a2a.utils.helpers import canonicalize_agent_card
7+
68

79
try:
810
import jwt
@@ -48,30 +50,6 @@ class ProtectedHeader(TypedDict):
4850
"""
4951

5052

51-
def clean_empty(d: Any) -> Any:
52-
"""Recursively remove empty strings, lists and dicts from a dictionary."""
53-
if isinstance(d, dict):
54-
cleaned_dict: dict[Any, Any] = {k: clean_empty(v) for k, v in d.items()}
55-
return {k: v for k, v in cleaned_dict.items() if v}
56-
if isinstance(d, list):
57-
cleaned_list: list[Any] = [clean_empty(v) for v in d]
58-
return [v for v in cleaned_list if v]
59-
return d if d not in ['', [], {}] else None
60-
61-
62-
def canonicalize_agent_card(agent_card: AgentCard) -> str:
63-
"""Canonicalizes the Agent Card JSON according to RFC 8785 (JCS)."""
64-
card_dict = agent_card.model_dump(
65-
exclude={'signatures'},
66-
exclude_defaults=True,
67-
exclude_none=True,
68-
by_alias=True,
69-
)
70-
# Recursively remove empty values
71-
cleaned_dict = clean_empty(card_dict)
72-
return json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True)
73-
74-
7553
def create_agent_card_signer(
7654
signing_key: PyJWK | str | bytes,
7755
protected_header: ProtectedHeader,
@@ -141,7 +119,6 @@ def signature_verifier(
141119
if not agent_card.signatures:
142120
raise NoSignatureError('AgentCard has no signatures to verify.')
143121

144-
last_error = None
145122
for agent_card_signature in agent_card.signatures:
146123
try:
147124
# get verification key
@@ -166,13 +143,10 @@ def signature_verifier(
166143
)
167144
# Found a valid signature, exit the loop and function
168145
break
169-
except PyJWTError as e:
170-
last_error = e
146+
except PyJWTError:
171147
continue
172148
else:
173149
# This block runs only if the loop completes without a break
174-
raise InvalidSignaturesError(
175-
'No valid signature found'
176-
) from last_error
150+
raise InvalidSignaturesError('No valid signature found')
177151

178152
return signature_verifier

tests/utils/test_helpers.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
from a2a.types import (
99
Artifact,
10+
AgentCard,
11+
AgentCardSignature,
12+
AgentCapabilities,
13+
AgentSkill,
1014
Message,
1115
MessageSendParams,
1216
Part,
@@ -23,6 +27,7 @@
2327
build_text_artifact,
2428
create_task_obj,
2529
validate,
30+
canonicalize_agent_card,
2631
)
2732

2833

@@ -45,6 +50,34 @@
4550
'type': 'task',
4651
}
4752

53+
SAMPLE_AGENT_CARD: dict[str, Any] = {
54+
'name': 'Test Agent',
55+
'description': 'A test agent',
56+
'url': 'http://localhost',
57+
'version': '1.0.0',
58+
'capabilities': AgentCapabilities(
59+
streaming=None,
60+
push_notifications=True,
61+
),
62+
'default_input_modes': ['text/plain'],
63+
'default_output_modes': ['text/plain'],
64+
'documentation_url': None,
65+
'icon_url': '',
66+
'skills': [
67+
AgentSkill(
68+
id='skill1',
69+
name='Test Skill',
70+
description='A test skill',
71+
tags=['test'],
72+
)
73+
],
74+
'signatures': [
75+
AgentCardSignature(
76+
protected='protected_header', signature='test_signature'
77+
)
78+
],
79+
}
80+
4881

4982
# Test create_task_obj
5083
def test_create_task_obj():
@@ -328,3 +361,22 @@ def test_are_modalities_compatible_both_empty():
328361
)
329362
is True
330363
)
364+
365+
366+
def test_canonicalize_agent_card():
367+
"""Test canonicalize_agent_card with defaults, optionals, and exceptions.
368+
369+
- extensions is omitted as it's not set and optional.
370+
- protocolVersion is included because it's always added by canonicalize_agent_card.
371+
- signatures should be omitted.
372+
"""
373+
agent_card = AgentCard(**SAMPLE_AGENT_CARD)
374+
expected_jcs = (
375+
'{"capabilities":{"pushNotifications":true},'
376+
'"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],'
377+
'"description":"A test agent","name":"Test Agent",'
378+
'"skills":[{"description":"A test skill","id":"skill1","name":"Test Skill","tags":["test"]}],'
379+
'"url":"http://localhost","version":"1.0.0"}'
380+
)
381+
result = canonicalize_agent_card(agent_card)
382+
assert result == expected_jcs

tests/utils/test_signing.py

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@
99
AgentSkill,
1010
AgentCardSignature,
1111
)
12-
from a2a.utils.signing import (
13-
canonicalize_agent_card,
14-
create_agent_card_signer,
15-
create_signature_verifier,
16-
InvalidSignaturesError,
17-
)
12+
from a2a.utils import signing
1813
from typing import Any
1914
from jwt.utils import base64url_encode
2015

@@ -63,7 +58,7 @@ def test_signer_and_verifier_symmetric(sample_agent_card: AgentCard):
6358
key = 'key12345' # Using a simple symmetric key for HS256
6459
wrong_key = 'wrongkey'
6560

66-
agent_card_signer = create_agent_card_signer(
61+
agent_card_signer = signing.create_agent_card_signer(
6762
signing_key=key,
6863
protected_header={
6964
'alg': 'HS384',
@@ -81,19 +76,19 @@ def test_signer_and_verifier_symmetric(sample_agent_card: AgentCard):
8176
assert signature.signature is not None
8277

8378
# Verify the signature
84-
verifier = create_signature_verifier(
79+
verifier = signing.create_signature_verifier(
8580
create_key_provider(key), ['HS256', 'HS384', 'ES256', 'RS256']
8681
)
8782
try:
8883
verifier(signed_card)
89-
except InvalidSignaturesError:
84+
except signing.InvalidSignaturesError:
9085
pytest.fail('Signature verification failed with correct key')
9186

9287
# Verify with wrong key
93-
verifier_wrong_key = create_signature_verifier(
88+
verifier_wrong_key = signing.create_signature_verifier(
9489
create_key_provider(wrong_key), ['HS256', 'HS384', 'ES256', 'RS256']
9590
)
96-
with pytest.raises(InvalidSignaturesError):
91+
with pytest.raises(signing.InvalidSignaturesError):
9792
verifier_wrong_key(signed_card)
9893

9994

@@ -111,7 +106,7 @@ def test_signer_and_verifier_symmetric_multiple_signatures(
111106
key = 'key12345' # Using a simple symmetric key for HS256
112107
wrong_key = 'wrongkey'
113108

114-
agent_card_signer = create_agent_card_signer(
109+
agent_card_signer = signing.create_agent_card_signer(
115110
signing_key=key,
116111
protected_header={
117112
'alg': 'HS384',
@@ -129,19 +124,19 @@ def test_signer_and_verifier_symmetric_multiple_signatures(
129124
assert signature.signature is not None
130125

131126
# Verify the signature
132-
verifier = create_signature_verifier(
127+
verifier = signing.create_signature_verifier(
133128
create_key_provider(key), ['HS256', 'HS384', 'ES256', 'RS256']
134129
)
135130
try:
136131
verifier(signed_card)
137-
except InvalidSignaturesError:
132+
except signing.InvalidSignaturesError:
138133
pytest.fail('Signature verification failed with correct key')
139134

140135
# Verify with wrong key
141-
verifier_wrong_key = create_signature_verifier(
136+
verifier_wrong_key = signing.create_signature_verifier(
142137
create_key_provider(wrong_key), ['HS256', 'HS384', 'ES256', 'RS256']
143138
)
144-
with pytest.raises(InvalidSignaturesError):
139+
with pytest.raises(signing.InvalidSignaturesError):
145140
verifier_wrong_key(signed_card)
146141

147142

@@ -156,7 +151,7 @@ def test_signer_and_verifier_asymmetric(sample_agent_card: AgentCard):
156151
)
157152
public_key_error = private_key_error.public_key()
158153

159-
agent_card_signer = create_agent_card_signer(
154+
agent_card_signer = signing.create_agent_card_signer(
160155
signing_key=private_key,
161156
protected_header={
162157
'alg': 'ES256',
@@ -173,43 +168,18 @@ def test_signer_and_verifier_asymmetric(sample_agent_card: AgentCard):
173168
assert signature.protected is not None
174169
assert signature.signature is not None
175170

176-
verifier = create_signature_verifier(
171+
verifier = signing.create_signature_verifier(
177172
create_key_provider(public_key), ['HS256', 'HS384', 'ES256', 'RS256']
178173
)
179174
try:
180175
verifier(signed_card)
181-
except InvalidSignaturesError:
176+
except signing.InvalidSignaturesError:
182177
pytest.fail('Signature verification failed with correct key')
183178

184179
# Verify with wrong key
185-
verifier_wrong_key = create_signature_verifier(
180+
verifier_wrong_key = signing.create_signature_verifier(
186181
create_key_provider(public_key_error),
187182
['HS256', 'HS384', 'ES256', 'RS256'],
188183
)
189-
with pytest.raises(InvalidSignaturesError):
184+
with pytest.raises(signing.InvalidSignaturesError):
190185
verifier_wrong_key(signed_card)
191-
192-
193-
def test_canonicalize_agent_card(
194-
sample_agent_card: AgentCard,
195-
):
196-
"""Test canonicalize_agent_card with defaults, optionals, and exceptions.
197-
198-
- extensions is omitted as it's not set and optional.
199-
- protocolVersion is included because it's always added by canonicalize_agent_card.
200-
- signatures should be omitted.
201-
"""
202-
sample_agent_card.signatures = [
203-
AgentCardSignature(
204-
protected='protected_header', signature='test_signature'
205-
)
206-
]
207-
expected_jcs = (
208-
'{"capabilities":{"pushNotifications":true},'
209-
'"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],'
210-
'"description":"A test agent","name":"Test Agent",'
211-
'"skills":[{"description":"A test skill","id":"skill1","name":"Test Skill","tags":["test"]}],'
212-
'"url":"http://localhost","version":"1.0.0"}'
213-
)
214-
result = canonicalize_agent_card(sample_agent_card)
215-
assert result == expected_jcs

0 commit comments

Comments
 (0)