-
Notifications
You must be signed in to change notification settings - Fork 83
feat(a2acrypto): add AgentCard JWS signing and verification (fixes #141) #368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
162f0ae
86b11d7
ef67e4a
ea92c7f
a105bc7
d771463
c14378b
6a92978
654b44b
f0070f0
901fcbd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import ( | |
| "strings" | ||
|
|
||
| "github.com/a2aproject/a2a-go/v2/a2a" | ||
| "github.com/a2aproject/a2a-go/v2/a2acrypto" | ||
| "github.com/a2aproject/a2a-go/v2/log" | ||
|
|
||
| "golang.org/x/mod/semver" | ||
|
|
@@ -33,6 +34,7 @@ type Factory struct { | |
| config Config | ||
| interceptors []CallInterceptor | ||
| transports map[transportKey]TransportFactory | ||
| cardVerifier *a2acrypto.Verifier | ||
| } | ||
|
|
||
| type transportKey struct { | ||
|
|
@@ -109,6 +111,13 @@ func (f *Factory) CreateFromCard(ctx context.Context, card *a2a.AgentCard) (*Cli | |
| protocolVersion: a2a.ProtocolVersion(selected.semver[1:]), | ||
| } | ||
| client.card.Store(card) | ||
| if f.cardVerifier != nil && len(card.Signatures) > 0 { | ||
| for _, sig := range card.Signatures { | ||
| if err := f.cardVerifier.Verify(card, &sig); err != nil { | ||
| return nil, fmt.Errorf("agent card signature verification failed: %w", err) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+114
to
+128
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Requiring all signatures to be valid breaks key rotation. The A2A specification states that verifying at least one signature is sufficient. Additionally, allowing unsigned cards to pass through silently when a if f.cardVerifier != nil && len(card.Signatures) > 0 {
var verified bool
var lastErr error
for _, sig := range card.Signatures {
if err := f.cardVerifier.Verify(card, &sig); err == nil {
verified = true
break
} else {
lastErr = err
}
}
if !verified {
return nil, fmt.Errorf("agent card signature verification failed: %w", lastErr)
}
}References
|
||
| return client, nil | ||
| } | ||
|
|
||
|
|
@@ -272,6 +281,14 @@ func WithDefaultsDisabled() FactoryOption { | |
| return defaultsDisabledOpt{} | ||
| } | ||
|
|
||
| // WithCardVerifier sets a verifier to validate AgentCard signatures when creating clients. | ||
| // If set, signed cards are verified in CreateFromCard; unsigned cards pass through. | ||
| func WithCardVerifier(v *a2acrypto.Verifier) FactoryOption { | ||
| return factoryOptionFn(func(f *Factory) { | ||
| f.cardVerifier = v | ||
| }) | ||
| } | ||
|
|
||
| // NewFactory creates a new Factory applying the provided configurations. | ||
| func NewFactory(options ...FactoryOption) *Factory { | ||
| f := &Factory{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| // Copyright 2026 The A2A Authors | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| // Package a2acrypto provides utilities for AgentCard JWS signing and verification. | ||
| package a2acrypto | ||
|
|
||
| import ( | ||
| "crypto" | ||
| ) | ||
|
Comment on lines
+18
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To comply with the A2A specification and RFC 8785 (JSON Canonicalization Scheme / JCS), the Since Go's standard import (
"crypto"
"encoding/json"
"errors"
"github.com/a2aproject/a2a-go/v2/a2a"
)
func preparePayload(card *a2a.AgentCard) ([]byte, error) {
if card == nil {
return nil, errors.New("nil agent card")
}
temp, err := json.Marshal(card)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(temp, &m); err != nil {
return nil, err
}
delete(m, "signatures")
return json.Marshal(m)
}References
|
||
|
|
||
| // KeyResolver resolves a key ID and JWKS URL into a public key for verification. | ||
| type KeyResolver interface { | ||
| ResolveKey(kid, jku string) (crypto.PublicKey, error) | ||
| } | ||
|
|
||
| // VerifierConfig configures signature verification. | ||
| type VerifierConfig struct { | ||
| KeyResolver KeyResolver | ||
| } | ||
|
|
||
| // Verifier verifies AgentCard JWS signatures. | ||
| type Verifier struct { | ||
| kr KeyResolver | ||
| } | ||
|
|
||
| // NewVerifier creates a Verifier using the provided configuration. | ||
| func NewVerifier(config VerifierConfig) *Verifier { | ||
| return &Verifier{kr: config.KeyResolver} | ||
| } | ||
|
|
||
| // SignerConfig configures AgentCard signing. | ||
| type SignerConfig struct { | ||
| PrivateKey crypto.Signer | ||
| KeyID string | ||
| Algorithm string | ||
| JWKSURL string | ||
| } | ||
|
|
||
| // Signer creates JWS signatures for AgentCards. | ||
| type Signer struct { | ||
| key crypto.Signer | ||
| kid string | ||
| algorithm string | ||
| jwksURL string | ||
| } | ||
|
|
||
| // NewSigner creates a Signer using the provided configuration. | ||
| func NewSigner(config SignerConfig) *Signer { | ||
| alg := config.Algorithm | ||
| if alg == "" { | ||
| alg = inferAlgorithm(config.PrivateKey) | ||
| } | ||
| return &Signer{ | ||
| key: config.PrivateKey, | ||
| kid: config.KeyID, | ||
| algorithm: alg, | ||
| jwksURL: config.JWKSURL, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package a2acrypto | ||
|
|
||
| import ( | ||
| "crypto" | ||
| "crypto/ecdsa" | ||
| "crypto/ed25519" | ||
| "crypto/elliptic" | ||
| "crypto/rsa" | ||
| "fmt" | ||
| ) | ||
|
|
||
| func inferAlgorithm(key crypto.Signer) string { | ||
| switch pub := key.Public().(type) { | ||
| case *ecdsa.PublicKey: | ||
| switch pub.Curve { | ||
| case elliptic.P256(): | ||
| return "ES256" | ||
| case elliptic.P384(): | ||
| return "ES384" | ||
| case elliptic.P521(): | ||
| return "ES512" | ||
| default: | ||
| return "" | ||
| } | ||
| case ed25519.PublicKey: | ||
| return "EdDSA" | ||
| case *rsa.PublicKey: | ||
| return "RS256" | ||
| default: | ||
| return "" | ||
| } | ||
| } | ||
|
|
||
| func algToHash(alg string) (crypto.Hash, error) { | ||
| switch alg { | ||
| case "ES256", "RS256": | ||
| return crypto.SHA256, nil | ||
| case "ES384", "RS384": | ||
| return crypto.SHA384, nil | ||
| case "ES512", "RS512": | ||
| return crypto.SHA512, nil | ||
| case "EdDSA": | ||
| return crypto.Hash(0), nil | ||
| default: | ||
| return 0, fmt.Errorf("unsupported algorithm: %s", alg) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package a2acrypto | ||
|
|
||
| import ( | ||
| "crypto/elliptic" | ||
| "encoding/asn1" | ||
| "fmt" | ||
| "math/big" | ||
| ) | ||
|
|
||
| // ecdsaSignature is the ASN.1 structure used by Go's crypto/ecdsa. | ||
| type ecdsaSignature struct { | ||
| R, S *big.Int | ||
| } | ||
|
|
||
| // marshalECDSASignature converts DER-encoded ECDSA signature to raw R||S (JWS format). | ||
| func marshalECDSASignature(der []byte, curve elliptic.Curve) ([]byte, error) { | ||
| var sig ecdsaSignature | ||
| if _, err := asn1.Unmarshal(der, &sig); err != nil { | ||
| return nil, fmt.Errorf("failed to unmarshal DER signature: %w", err) | ||
| } | ||
| keySize := (curve.Params().BitSize + 7) / 8 | ||
| out := make([]byte, 2*keySize) | ||
| sig.R.FillBytes(out[:keySize]) | ||
| sig.S.FillBytes(out[keySize:]) | ||
| return out, nil | ||
| } | ||
|
|
||
| // unmarshalECDSASignature converts raw R||S to r, s *big.Int for verification. | ||
| func unmarshalECDSASignature(raw []byte, curve elliptic.Curve) (r, s *big.Int, err error) { | ||
| keySize := (curve.Params().BitSize + 7) / 8 | ||
| if len(raw) != 2*keySize { | ||
| return nil, nil, fmt.Errorf("raw signature length %d, want %d", len(raw), 2*keySize) | ||
| } | ||
| r = new(big.Int).SetBytes(raw[:keySize]) | ||
| s = new(big.Int).SetBytes(raw[keySize:]) | ||
| return r, s, nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package a2acrypto | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| "github.com/a2aproject/a2a-go/v2/a2a" | ||
| "github.com/a2aproject/a2a-go/v2/a2asrv" | ||
| ) | ||
|
|
||
| // NewSignedCardProducer wraps an AgentCardProducer to automatically sign every | ||
| // produced AgentCard before returning it. | ||
| func NewSignedCardProducer(signer *Signer, wrapped a2asrv.AgentCardProducer) a2asrv.AgentCardProducer { | ||
| return &signedCardProducer{signer: signer, wrapped: wrapped} | ||
| } | ||
|
|
||
| type signedCardProducer struct { | ||
| signer *Signer | ||
| wrapped a2asrv.AgentCardProducer | ||
| } | ||
|
|
||
| func (p *signedCardProducer) Card(ctx context.Context) (*a2a.AgentCard, error) { | ||
| card, err := p.wrapped.Card(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| sig, err := p.signer.Sign(card) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to sign agent card: %w", err) | ||
| } | ||
| card.Signatures = append(card.Signatures, *sig) | ||
| return card, nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package a2acrypto | ||
|
|
||
| import ( | ||
| "crypto" | ||
| "crypto/ecdsa" | ||
| "crypto/rand" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
|
|
||
| "github.com/a2aproject/a2a-go/v2/a2a" | ||
| ) | ||
|
|
||
| // Sign computes a JWS signature for the given AgentCard. | ||
| // It serializes the card to JSON, builds the protected header, and signs. | ||
| func (s *Signer) Sign(card *a2a.AgentCard) (*a2a.AgentCardSignature, error) { | ||
| payload, err := json.Marshal(card) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to marshal agent card: %w", err) | ||
| } | ||
|
|
||
| protected := map[string]any{ | ||
| "alg": s.algorithm, | ||
| "kid": s.kid, | ||
| } | ||
|
Comment on lines
+32
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the func (s *Signer) Sign(card *a2a.AgentCard) (*a2a.AgentCardSignature, error) {
payload, err := preparePayload(card)
if err != nil {
return nil, fmt.Errorf("failed to prepare agent card payload: %w", err)
}
protected := map[string]any{
"alg": s.algorithm,
"typ": "JOSE",
"kid": s.kid,
}References
|
||
| if s.jwksURL != "" { | ||
| protected["jku"] = s.jwksURL | ||
| } | ||
|
|
||
| protectedJSON, err := json.Marshal(protected) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to marshal protected header: %w", err) | ||
| } | ||
|
|
||
| protectedB64 := base64.RawURLEncoding.EncodeToString(protectedJSON) | ||
| payloadB64 := base64.RawURLEncoding.EncodeToString(payload) | ||
| signingInput := protectedB64 + "." + payloadB64 | ||
|
|
||
| hash, err := algToHash(s.algorithm) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var signature []byte | ||
| if hash == 0 { | ||
| signature, err = s.key.Sign(rand.Reader, []byte(signingInput), crypto.Hash(0)) | ||
| } else { | ||
| h := hash.New() | ||
| h.Write([]byte(signingInput)) | ||
| signature, err = s.key.Sign(rand.Reader, h.Sum(nil), hash) | ||
| } | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to sign: %w", err) | ||
| } | ||
|
|
||
| // Convert ECDSA DER output to raw R||S for JWS compatibility. | ||
| if _, ok := s.key.Public().(*ecdsa.PublicKey); ok { | ||
| signature, err = marshalECDSASignature(signature, s.key.Public().(*ecdsa.PublicKey).Curve) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to convert ECDSA signature: %w", err) | ||
| } | ||
| } | ||
|
|
||
| return &a2a.AgentCardSignature{ | ||
| Protected: protectedB64, | ||
| Signature: base64.RawURLEncoding.EncodeToString(signature), | ||
| }, nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Requiring all signatures to be valid breaks key rotation (where some signatures might use old/expired keys or keys not present in the client's trust store). The A2A specification states that verifying at least one signature is sufficient.
Additionally, allowing unsigned cards to pass through silently when a
Verifieris configured makes the client vulnerable to signature-stripping attacks (where an attacker simply removes thesignaturesfield to bypass verification). Consider making signature enforcement configurable or requiring at least one valid signature if a verifier is set.References