Skip to content

Commit 162f0ae

Browse files
author
邝谧
committed
feat(a2acrypto): add AgentCard JWS signing and verification package (fixes #141)
Add a2acrypto package for AgentCard JWS (RFC 7515) signing and verification, with integrations into a2aclient and a2asrv. Core types: - Signer: Creates JWS signatures for AgentCards (ES256/ES384/ES512/ EdDSA/RS256), with automatic algorithm inference - Verifier: Verifies AgentCard signatures via KeyResolver - KeyResolver: Resolves kid/jku to crypto.PublicKey - NewSignedCardProducer: Wraps AgentCardProducer to auto-sign cards Integrations: - a2aclient.WithCardVerifier: Verify signed cards in CreateFromCard - agentcard.Resolver.Verifier: Verify cards after HTTP resolution Tests cover ECDSA P-256, Ed25519, tampered cards, nil signatures, algorithm inference, and protected header fields.
1 parent d52d5a1 commit 162f0ae

9 files changed

Lines changed: 568 additions & 0 deletions

File tree

a2aclient/agentcard/resolver.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"time"
2525

2626
"github.com/a2aproject/a2a-go/v2/a2a"
27+
"github.com/a2aproject/a2a-go/v2/a2acrypto"
2728
"github.com/a2aproject/a2a-go/v2/log"
2829
)
2930

@@ -62,6 +63,8 @@ type Resolver struct {
6263
Client *http.Client
6364
// CardParser can be used to configure AgentCard parsing.
6465
CardParser Parser
66+
// Verifier optionally verifies AgentCard signatures after resolution.
67+
Verifier *a2acrypto.Verifier
6568
}
6669

6770
// NewResolver is a [Resolver] constructor function.
@@ -131,6 +134,14 @@ func (r *Resolver) Resolve(ctx context.Context, baseURL string, opts ...ResolveO
131134
return nil, fmt.Errorf("card parsing failed: %w", err)
132135
}
133136

137+
if r.Verifier != nil && len(card.Signatures) > 0 {
138+
for _, sig := range card.Signatures {
139+
if err := r.Verifier.Verify(card, &sig); err != nil {
140+
return nil, fmt.Errorf("agent card signature verification failed: %w", err)
141+
}
142+
}
143+
}
144+
134145
return card, nil
135146
}
136147

a2aclient/factory.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"strings"
2323

2424
"github.com/a2aproject/a2a-go/v2/a2a"
25+
"github.com/a2aproject/a2a-go/v2/a2acrypto"
2526
"github.com/a2aproject/a2a-go/v2/log"
2627

2728
"golang.org/x/mod/semver"
@@ -33,6 +34,7 @@ type Factory struct {
3334
config Config
3435
interceptors []CallInterceptor
3536
transports map[transportKey]TransportFactory
37+
cardVerifier *a2acrypto.Verifier
3638
}
3739

3840
type transportKey struct {
@@ -109,6 +111,13 @@ func (f *Factory) CreateFromCard(ctx context.Context, card *a2a.AgentCard) (*Cli
109111
protocolVersion: a2a.ProtocolVersion(selected.semver[1:]),
110112
}
111113
client.card.Store(card)
114+
if f.cardVerifier != nil && len(card.Signatures) > 0 {
115+
for _, sig := range card.Signatures {
116+
if err := f.cardVerifier.Verify(card, &sig); err != nil {
117+
return nil, fmt.Errorf("agent card signature verification failed: %w", err)
118+
}
119+
}
120+
}
112121
return client, nil
113122
}
114123

@@ -272,6 +281,14 @@ func WithDefaultsDisabled() FactoryOption {
272281
return defaultsDisabledOpt{}
273282
}
274283

284+
// WithCardVerifier sets a verifier to validate AgentCard signatures when creating clients.
285+
// If set, signed cards are verified in CreateFromCard; unsigned cards pass through.
286+
func WithCardVerifier(v *a2acrypto.Verifier) FactoryOption {
287+
return factoryOptionFn(func(f *Factory) {
288+
f.cardVerifier = v
289+
})
290+
}
291+
275292
// NewFactory creates a new Factory applying the provided configurations.
276293
func NewFactory(options ...FactoryOption) *Factory {
277294
f := &Factory{

a2acrypto/a2acrypto.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2026 The A2A Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package a2acrypto provides utilities for AgentCard JWS signing and verification.
16+
package a2acrypto
17+
18+
import (
19+
"crypto"
20+
)
21+
22+
// KeyResolver resolves a key ID and JWKS URL into a public key for verification.
23+
type KeyResolver interface {
24+
ResolveKey(kid, jku string) (crypto.PublicKey, error)
25+
}
26+
27+
// VerifierConfig configures signature verification.
28+
type VerifierConfig struct {
29+
KeyResolver KeyResolver
30+
}
31+
32+
// Verifier verifies AgentCard JWS signatures.
33+
type Verifier struct {
34+
kr KeyResolver
35+
}
36+
37+
// NewVerifier creates a Verifier using the provided configuration.
38+
func NewVerifier(config VerifierConfig) *Verifier {
39+
return &Verifier{kr: config.KeyResolver}
40+
}
41+
42+
// SignerConfig configures AgentCard signing.
43+
type SignerConfig struct {
44+
PrivateKey crypto.Signer
45+
KeyID string
46+
Algorithm string
47+
JWKSURL string
48+
}
49+
50+
// Signer creates JWS signatures for AgentCards.
51+
type Signer struct {
52+
key crypto.Signer
53+
kid string
54+
algorithm string
55+
jwksURL string
56+
}
57+
58+
// NewSigner creates a Signer using the provided configuration.
59+
func NewSigner(config SignerConfig) *Signer {
60+
alg := config.Algorithm
61+
if alg == "" {
62+
alg = inferAlgorithm(config.PrivateKey)
63+
}
64+
return &Signer{
65+
key: config.PrivateKey,
66+
kid: config.KeyID,
67+
algorithm: alg,
68+
jwksURL: config.JWKSURL,
69+
}
70+
}

a2acrypto/alg.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package a2acrypto
2+
3+
import (
4+
"crypto"
5+
"crypto/ecdsa"
6+
"crypto/ed25519"
7+
"crypto/elliptic"
8+
"crypto/rsa"
9+
"fmt"
10+
)
11+
12+
func inferAlgorithm(key crypto.Signer) string {
13+
switch pub := key.Public().(type) {
14+
case *ecdsa.PublicKey:
15+
switch pub.Curve {
16+
case elliptic.P256():
17+
return "ES256"
18+
case elliptic.P384():
19+
return "ES384"
20+
case elliptic.P521():
21+
return "ES512"
22+
default:
23+
return ""
24+
}
25+
case ed25519.PublicKey:
26+
return "EdDSA"
27+
case *rsa.PublicKey:
28+
return "RS256"
29+
default:
30+
return ""
31+
}
32+
}
33+
34+
func algToHash(alg string) (crypto.Hash, error) {
35+
switch alg {
36+
case "ES256", "RS256":
37+
return crypto.SHA256, nil
38+
case "ES384", "RS384":
39+
return crypto.SHA384, nil
40+
case "ES512", "RS512":
41+
return crypto.SHA512, nil
42+
case "EdDSA":
43+
return crypto.Hash(0), nil
44+
default:
45+
return 0, fmt.Errorf("unsupported algorithm: %s", alg)
46+
}
47+
}

a2acrypto/ecdsa.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package a2acrypto
2+
3+
import (
4+
"crypto/elliptic"
5+
"encoding/asn1"
6+
"fmt"
7+
"math/big"
8+
)
9+
10+
// ecdsaSignature is the ASN.1 structure used by Go's crypto/ecdsa.
11+
type ecdsaSignature struct {
12+
R, S *big.Int
13+
}
14+
15+
// marshalECDSASignature converts DER-encoded ECDSA signature to raw R||S (JWS format).
16+
func marshalECDSASignature(der []byte, curve elliptic.Curve) ([]byte, error) {
17+
var sig ecdsaSignature
18+
if _, err := asn1.Unmarshal(der, &sig); err != nil {
19+
return nil, fmt.Errorf("failed to unmarshal DER signature: %w", err)
20+
}
21+
keySize := (curve.Params().BitSize + 7) / 8
22+
out := make([]byte, 2*keySize)
23+
sig.R.FillBytes(out[:keySize])
24+
sig.S.FillBytes(out[keySize:])
25+
return out, nil
26+
}
27+
28+
// unmarshalECDSASignature converts raw R||S to r, s *big.Int for verification.
29+
func unmarshalECDSASignature(raw []byte, curve elliptic.Curve) (r, s *big.Int, err error) {
30+
keySize := (curve.Params().BitSize + 7) / 8
31+
if len(raw) != 2*keySize {
32+
return nil, nil, fmt.Errorf("raw signature length %d, want %d", len(raw), 2*keySize)
33+
}
34+
r = new(big.Int).SetBytes(raw[:keySize])
35+
s = new(big.Int).SetBytes(raw[keySize:])
36+
return r, s, nil
37+
}

a2acrypto/producer.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package a2acrypto
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/a2aproject/a2a-go/v2/a2a"
8+
"github.com/a2aproject/a2a-go/v2/a2asrv"
9+
)
10+
11+
// NewSignedCardProducer wraps an AgentCardProducer to automatically sign every
12+
// produced AgentCard before returning it.
13+
func NewSignedCardProducer(signer *Signer, wrapped a2asrv.AgentCardProducer) a2asrv.AgentCardProducer {
14+
return &signedCardProducer{signer: signer, wrapped: wrapped}
15+
}
16+
17+
type signedCardProducer struct {
18+
signer *Signer
19+
wrapped a2asrv.AgentCardProducer
20+
}
21+
22+
func (p *signedCardProducer) Card(ctx context.Context) (*a2a.AgentCard, error) {
23+
card, err := p.wrapped.Card(ctx)
24+
if err != nil {
25+
return nil, err
26+
}
27+
sig, err := p.signer.Sign(card)
28+
if err != nil {
29+
return nil, fmt.Errorf("failed to sign agent card: %w", err)
30+
}
31+
card.Signatures = append(card.Signatures, *sig)
32+
return card, nil
33+
}

a2acrypto/sign.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package a2acrypto
2+
3+
import (
4+
"crypto"
5+
"crypto/ecdsa"
6+
"crypto/rand"
7+
"encoding/base64"
8+
"encoding/json"
9+
"fmt"
10+
11+
"github.com/a2aproject/a2a-go/v2/a2a"
12+
)
13+
14+
// Sign computes a JWS signature for the given AgentCard.
15+
// It serializes the card to JSON, builds the protected header, and signs.
16+
func (s *Signer) Sign(card *a2a.AgentCard) (*a2a.AgentCardSignature, error) {
17+
payload, err := json.Marshal(card)
18+
if err != nil {
19+
return nil, fmt.Errorf("failed to marshal agent card: %w", err)
20+
}
21+
22+
protected := map[string]any{
23+
"alg": s.algorithm,
24+
"kid": s.kid,
25+
}
26+
if s.jwksURL != "" {
27+
protected["jku"] = s.jwksURL
28+
}
29+
30+
protectedJSON, err := json.Marshal(protected)
31+
if err != nil {
32+
return nil, fmt.Errorf("failed to marshal protected header: %w", err)
33+
}
34+
35+
protectedB64 := base64.RawURLEncoding.EncodeToString(protectedJSON)
36+
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
37+
signingInput := protectedB64 + "." + payloadB64
38+
39+
hash, err := algToHash(s.algorithm)
40+
if err != nil {
41+
return nil, err
42+
}
43+
44+
var signature []byte
45+
if hash == 0 {
46+
signature, err = s.key.Sign(rand.Reader, []byte(signingInput), crypto.Hash(0))
47+
} else {
48+
h := hash.New()
49+
h.Write([]byte(signingInput))
50+
signature, err = s.key.Sign(rand.Reader, h.Sum(nil), hash)
51+
}
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to sign: %w", err)
54+
}
55+
56+
// Convert ECDSA DER output to raw R||S for JWS compatibility.
57+
if _, ok := s.key.Public().(*ecdsa.PublicKey); ok {
58+
signature, err = marshalECDSASignature(signature, s.key.Public().(*ecdsa.PublicKey).Curve)
59+
if err != nil {
60+
return nil, fmt.Errorf("failed to convert ECDSA signature: %w", err)
61+
}
62+
}
63+
64+
return &a2a.AgentCardSignature{
65+
Protected: protectedB64,
66+
Signature: base64.RawURLEncoding.EncodeToString(signature),
67+
}, nil
68+
}

0 commit comments

Comments
 (0)