Skip to main content

zeph_a2a/
card_signing.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! JWS signature verification for [`AgentCard`](crate::AgentCard)s (A2A 1.0.0 §8.4).
5//!
6//! An [`AgentCardSignature`] covers the RFC 8785 JCS
7//! canonicalization of the card's JSON representation with the `signatures` key
8//! removed. Verification requires an out-of-band trusted-key store — this module
9//! deliberately does **not** fetch keys from a card-supplied `jku` URL.
10//!
11//! # Trust model
12//!
13//! `jku`/JWKS auto-fetch is not implemented.
14//!
15//! // TODO(critic): jku/JWKS fetch deferred — SSRF risk on attacker-controlled URL;
16//! // out-of-band key store is the trust anchor (#5928 follow-up).
17//!
18//! An attacker who can forge an entire card can also point a `jku` at a JWKS they
19//! control and self-sign; only a pre-shared, operator-configured [`TrustedKey`] store
20//! closes that gap. See [`TrustedKey`].
21//!
22//! # Algorithm support
23//!
24//! Only ES256 (ECDSA P-256, JWS `alg: "ES256"`) is supported today, per the A2A spec's
25//! mandatory example. `EdDSA` and RS256 are deferred (D4) — a signature using an
26//! unrecognized `alg` resolves to [`SignatureVerification::Unverifiable`].
27//!
28//! # Feature flag
29//!
30//! The [`SignatureVerification`], [`SigAlg`], and [`TrustedKey`] types are always
31//! compiled (needed for config plumbing and the crypto-free URL-origin check in
32//! [`crate::discovery`]). [`verify_card_signatures`] and [`sign_card`] require the
33//! `card-signing` feature; without it, [`verify_card_signatures`] returns
34//! [`SignatureVerification::FeatureDisabled`] and `sign_card` does not exist at all
35//! (it has no meaningful behavior to fall back to — signing requires the crypto crates).
36//!
37//! # Known limitation — unvalidated against a real peer
38//!
39//! The JCS canonicalization and signing-input construction below were implemented from
40//! the A2A 1.0.0 spec text (§8.4.1–§8.4.3) retrieved verbatim during design review, not
41//! from a real signed-card test vector produced by a reference implementation (e.g. the
42//! Python/JS `a2a-sdk`). `canonical_payload` (private, used by both [`verify_card_signatures`]
43//! and [`sign_card`]) strips proto3-default-valued fields (empty
44//! string, `false`, `0`, empty array/object, recursively through nested objects) before
45//! JCS, matching the spec text's canonicalization rules — this closes the specific
46//! divergence a compliant signer that strips defaults before signing would otherwise
47//! trigger against our verifier canonicalizing the full transmitted card (#6201; see
48//! `signature_over_default_stripped_payload_verifies_against_full_transmitted_card`
49//! below). This, `self_signed_round_trip_verifies`, and
50//! `raw_json_canonicalization_differs_from_typed_struct_reserialization` prove internal
51//! self-consistency and guard the bug classes this module exists to avoid, but none of
52//! them prove interoperability with a real A2A peer's signer. Treat `require` as unproven
53//! until a real vector is obtained and checked in.
54
55#[cfg(feature = "card-signing")]
56use base64::Engine as _;
57#[cfg(feature = "card-signing")]
58use base64::engine::general_purpose::URL_SAFE_NO_PAD;
59use serde_json::Value;
60
61use crate::types::AgentCardSignature;
62
63/// Signature algorithm identifiers recognized when verifying an [`AgentCardSignature`].
64///
65/// Always compiled — used by config plumbing ([`TrustedKey`]) regardless of whether the
66/// `card-signing` feature is enabled. Only [`Es256`](SigAlg::Es256) has a cryptographic
67/// implementation today (D4: EdDSA/RS256 deferred).
68#[non_exhaustive]
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum SigAlg {
71    /// ECDSA using the P-256 curve and SHA-256 (JWS `alg: "ES256"`).
72    Es256,
73}
74
75impl SigAlg {
76    /// Parse a JWS `alg` header value into a [`SigAlg`], or `None` if unrecognized.
77    ///
78    /// # Examples
79    ///
80    /// ```rust
81    /// use zeph_a2a::card_signing::SigAlg;
82    ///
83    /// assert_eq!(SigAlg::from_jws_alg("ES256"), Some(SigAlg::Es256));
84    /// assert_eq!(SigAlg::from_jws_alg("EdDSA"), None);
85    /// ```
86    #[must_use]
87    pub fn from_jws_alg(alg: &str) -> Option<Self> {
88        match alg {
89            "ES256" => Some(Self::Es256),
90            _ => None,
91        }
92    }
93}
94
95/// A public key trusted to sign peer [`AgentCard`](crate::AgentCard)s, keyed by `kid`.
96///
97/// This is the trust anchor for card signature verification: the operator configures
98/// one entry per peer agent whose signature should be honored. There is no automatic
99/// key discovery (see the module docs for why `jku` fetch is deferred).
100///
101/// `key_material` accepts either a JWK JSON object (`{"kty":"EC","crv":"P-256","x":...,"y":...}`)
102/// or a PEM-encoded `SubjectPublicKeyInfo`. Parsing happens lazily on each verification
103/// attempt — verification is not on a hot path (`AgentRegistry::discover` has no runtime
104/// caller yet, see D3), so caching the parsed key is not worth the complexity.
105#[derive(Debug, Clone)]
106pub struct TrustedKey {
107    /// Key identifier, matched against the `kid` in a signature's protected header.
108    pub kid: String,
109    /// Algorithm this key is trusted to verify.
110    pub alg: SigAlg,
111    /// JWK JSON or PEM-encoded public key material.
112    pub key_material: String,
113}
114
115/// Outcome of verifying an [`AgentCard`](crate::AgentCard)'s signature(s) against a
116/// [`TrustedKey`] store.
117///
118/// This is a 3-way (plus [`FeatureDisabled`](Self::FeatureDisabled)) split rather than a
119/// bool because policy decisions (`ignore`/`prefer`/`require`) need to distinguish "no
120/// opinion" (`Unverifiable` — unsigned peer, or signed by an untrusted/unknown key) from
121/// an active tampering signal (`Invalid` — a trusted key's signature does not match).
122/// Treating both as "not verified" would let `prefer` silently accept a tampered card
123/// from a peer whose `kid` happens to be unknown.
124#[non_exhaustive]
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum SignatureVerification {
127    /// At least one signature verified against a trusted key.
128    Verified,
129    /// No signature could be checked: the card is unsigned, every signature's `kid`/`alg`
130    /// is not in the trust store, or a signature's protected header is malformed.
131    Unverifiable {
132        /// Human-readable reason, suitable for a `tracing::warn!` log line.
133        reason: String,
134    },
135    /// A signature matched a trusted key by `kid`/`alg` but cryptographic verification
136    /// failed, or the card's JSON could not be canonicalized. Signals tampering or a
137    /// canonicalization mismatch — never returned for an unsigned card.
138    Invalid {
139        /// Human-readable reason, suitable for a `tracing::warn!`/`tracing::error!` log line.
140        reason: String,
141    },
142    /// The crate was compiled without the `card-signing` feature, so no cryptographic
143    /// verification was attempted.
144    FeatureDisabled,
145}
146
147/// Errors from [`sign_card`] (test/tooling use only — see module docs on D5).
148#[cfg(feature = "card-signing")]
149#[derive(Debug, thiserror::Error)]
150#[non_exhaustive]
151pub enum CardSigningError {
152    /// RFC 8785 JCS canonicalization of the card JSON failed.
153    #[error("JCS canonicalization failed: {0}")]
154    Canonicalization(String),
155    /// The JWS protected header could not be serialized.
156    #[error("protected header serialization failed: {0}")]
157    HeaderSerialization(String),
158}
159
160/// Verify `signatures` (typically [`AgentCard::signatures`](crate::AgentCard::signatures))
161/// against `raw_card` and a `trusted_keys` store.
162///
163/// `raw_card` **must** be the raw JSON [`Value`] as received on the wire (e.g. parsed
164/// directly from the HTTP response body), never a re-serialization of the typed
165/// [`AgentCard`](crate::AgentCard) struct — see the module docs and the
166/// `raw_json_canonicalization_differs_from_typed_struct_reserialization` unit test for why
167/// that distinction is load-bearing, not stylistic.
168///
169/// All entries in `signatures` are evaluated — order in the wire array never affects the
170/// outcome. Returns [`SignatureVerification::Verified`] if **any** signature verifies
171/// against a trusted key, even if another entry in the same array is tampered or
172/// unresolvable (key rotation and multi-party attestation both put more than one signature
173/// on a card; a bad sibling signature must not veto a good one). Otherwise, returns
174/// [`SignatureVerification::Invalid`] if any signature matched a trusted key's `kid`/`alg`
175/// but failed cryptographic verification. Otherwise, returns
176/// [`SignatureVerification::Unverifiable`] if the card is unsigned or no signature resolves
177/// to a trusted key. Returns [`SignatureVerification::FeatureDisabled`] when compiled
178/// without the `card-signing` feature.
179///
180/// # Examples
181///
182/// ```rust
183/// use zeph_a2a::card_signing::{verify_card_signatures, SignatureVerification};
184/// use serde_json::json;
185///
186/// let raw_card = json!({"name": "peer", "url": "http://peer.example.com"});
187/// let result = verify_card_signatures(&raw_card, &[], &[]);
188/// assert!(matches!(
189///     result,
190///     SignatureVerification::Unverifiable { .. } | SignatureVerification::FeatureDisabled
191/// ));
192/// ```
193#[must_use]
194#[allow(clippy::needless_return, unused_variables)]
195pub fn verify_card_signatures(
196    raw_card: &Value,
197    signatures: &[AgentCardSignature],
198    trusted_keys: &[TrustedKey],
199) -> SignatureVerification {
200    #[cfg(not(feature = "card-signing"))]
201    {
202        return SignatureVerification::FeatureDisabled;
203    }
204    #[cfg(feature = "card-signing")]
205    {
206        if signatures.is_empty() {
207            return SignatureVerification::Unverifiable {
208                reason: "card carries no signatures".to_owned(),
209            };
210        }
211
212        let payload = match canonical_payload(raw_card) {
213            Ok(bytes) => bytes,
214            Err(e) => {
215                return SignatureVerification::Invalid {
216                    reason: format!("canonicalization failed: {e}"),
217                };
218            }
219        };
220
221        // Evaluate every signature before deciding — a tampered or unknown-kid signature
222        // earlier in the array must not veto a later signature that verifies (I1): the A2A
223        // spec's "verify >= 1 signature" and this function's own contract require checking
224        // all of them and taking `Verified` if any one verifies, regardless of position.
225        // Real scenarios this protects: key rotation (old+new signature during overlap),
226        // multi-party attestation. Precedence when none verify: Invalid > Unverifiable.
227        let mut last_unverifiable_reason = "no signature verified".to_owned();
228        let mut invalid_reason: Option<String> = None;
229        for sig in signatures {
230            match verify_one(&payload, sig, trusted_keys) {
231                imp::SigOutcome::Verified => return SignatureVerification::Verified,
232                imp::SigOutcome::Invalid(reason) => {
233                    invalid_reason.get_or_insert(reason);
234                }
235                imp::SigOutcome::Unverifiable(reason) => last_unverifiable_reason = reason,
236            }
237        }
238        match invalid_reason {
239            Some(reason) => SignatureVerification::Invalid { reason },
240            None => SignatureVerification::Unverifiable {
241                reason: last_unverifiable_reason,
242            },
243        }
244    }
245}
246
247/// Sign `raw_card` (raw JSON with `signatures` removed before canonicalization, per
248/// [`verify_card_signatures`]) with `signing_key`, producing an [`AgentCardSignature`].
249///
250/// This exists to build round-trip tests and interop vectors, mirroring
251/// [`crate::Ibct::issue`]/[`crate::Ibct::verify`]. Wiring this into the A2A server so it
252/// signs our own served card is deferred (D5) — see module docs.
253///
254/// # Errors
255///
256/// Returns [`CardSigningError::Canonicalization`] if `raw_card` cannot be JCS-canonicalized,
257/// or [`CardSigningError::HeaderSerialization`] if the protected header cannot be serialized.
258///
259/// # Examples
260///
261/// ```rust
262/// # #[cfg(feature = "card-signing")]
263/// # {
264/// use zeph_a2a::card_signing::sign_card;
265/// use p256::ecdsa::SigningKey;
266/// use serde_json::json;
267///
268/// let signing_key = SigningKey::from_bytes(&[7u8; 32].into()).unwrap();
269/// let raw_card = json!({"name": "my-agent", "url": "http://localhost:8080"});
270/// let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
271/// assert!(!sig.protected.is_empty());
272/// # }
273/// ```
274#[cfg(feature = "card-signing")]
275pub fn sign_card(
276    raw_card: &Value,
277    kid: &str,
278    signing_key: &p256::ecdsa::SigningKey,
279) -> Result<AgentCardSignature, CardSigningError> {
280    use p256::ecdsa::signature::Signer;
281
282    let payload = canonical_payload(raw_card).map_err(CardSigningError::Canonicalization)?;
283    let header = serde_json::json!({"alg": "ES256", "kid": kid});
284    let header_bytes = serde_json::to_vec(&header)
285        .map_err(|e| CardSigningError::HeaderSerialization(e.to_string()))?;
286    let protected = URL_SAFE_NO_PAD.encode(header_bytes);
287    let signing_input = format!("{protected}.{}", URL_SAFE_NO_PAD.encode(&payload));
288    let signature: p256::ecdsa::Signature = Signer::sign(signing_key, signing_input.as_bytes());
289    Ok(AgentCardSignature {
290        protected,
291        signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
292        header: None,
293    })
294}
295
296/// RFC 8785 JCS canonicalization of `raw_card` with the `signatures` key removed and
297/// proto3-default-valued fields stripped (#6201).
298///
299/// Operates on the raw received [`Value`] — never on a re-serialization of the typed
300/// [`AgentCard`](crate::AgentCard) struct. See module docs.
301///
302/// A compliant signer may drop proto3-default-valued fields (empty string, `false`, `0`,
303/// empty array/object) from the card JSON before canonicalizing and signing, per the A2A
304/// spec text, while the transmitted card still carries them explicitly. Stripping the same
305/// fields here — recursively, bottom-up so an object that becomes empty after its own
306/// fields are stripped is itself dropped from its parent — normalizes both shapes to the
307/// same canonical bytes, so a signature computed over either verifies against the other.
308#[cfg(feature = "card-signing")]
309fn canonical_payload(raw_card: &Value) -> Result<Vec<u8>, String> {
310    let mut card = raw_card.clone();
311    if let Value::Object(map) = &mut card {
312        map.remove("signatures");
313    }
314    strip_proto3_defaults(&mut card);
315    serde_json_canonicalizer::to_vec(&card).map_err(|e| e.to_string())
316}
317
318/// `true` when `value` is a proto3 default: empty string, `false`, `0` (integer or
319/// float), an empty array, or an empty object. `null` is not a proto3 JSON-mapping
320/// default value and is left untouched.
321// TODO(critic): the `{}` (empty object) and `0` (number) cases are the highest-risk,
322// unvalidated part of this heuristic (S2, #6201 follow-up). Proto3 JSON mapping has
323// *message presence*: a message field explicitly **set** to an empty message serializes
324// to `{}` and is distinct from a field left **unset** (which is omitted entirely) — a
325// real signer that emits `{}` for a deliberately-set-but-empty message would sign
326// *with* that key present, while this function strips it, reproducing the exact
327// canonical-bytes divergence #6201 exists to eliminate. The same shape applies to a
328// semantically meaningful `0`. This is invisible to every in-tree test because
329// `canonical_payload` is applied symmetrically to both `sign_card` and
330// `verify_card_signatures` (see module docs' "unvalidated against a real peer"
331// section) — it only bites against a real external `a2a-sdk` signer. If a real vector
332// ever mismatches specifically on an empty-object or zero-valued field, narrow this
333// function (e.g. drop the `{}`/`0` arms, keeping only string/bool/array) rather than
334// assuming the JCS library itself is at fault.
335#[cfg(feature = "card-signing")]
336fn is_proto3_default(value: &Value) -> bool {
337    match value {
338        Value::Null => false,
339        Value::Bool(b) => !b,
340        Value::Number(n) => n.as_f64() == Some(0.0),
341        Value::String(s) => s.is_empty(),
342        Value::Array(a) => a.is_empty(),
343        Value::Object(o) => o.is_empty(),
344    }
345}
346
347/// Recursively drops object keys whose value is a proto3 default (see
348/// [`is_proto3_default`]), processing children first so a nested object that becomes
349/// empty only after its own defaults are stripped is also removed from its parent.
350/// Array elements are recursed into but never removed — a repeated field's cardinality
351/// is significant and unlike a struct field has no "default value" to omit.
352#[cfg(feature = "card-signing")]
353fn strip_proto3_defaults(value: &mut Value) {
354    match value {
355        Value::Object(map) => {
356            map.retain(|_, v| {
357                strip_proto3_defaults(v);
358                !is_proto3_default(v)
359            });
360        }
361        Value::Array(arr) => {
362            for v in arr.iter_mut() {
363                strip_proto3_defaults(v);
364            }
365        }
366        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
367    }
368}
369
370#[cfg(feature = "card-signing")]
371mod imp {
372    use base64::Engine as _;
373    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
374    use p256::ecdsa::signature::Verifier;
375
376    use super::{SigAlg, TrustedKey};
377    use crate::types::AgentCardSignature;
378
379    pub(super) enum SigOutcome {
380        Verified,
381        Unverifiable(String),
382        Invalid(String),
383    }
384
385    struct ProtectedHeader {
386        alg: String,
387        kid: Option<String>,
388    }
389
390    fn decode_protected_header(protected_b64: &str) -> Option<ProtectedHeader> {
391        let bytes = URL_SAFE_NO_PAD.decode(protected_b64).ok()?;
392        let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
393        let alg = v.get("alg")?.as_str()?.to_owned();
394        let kid = v
395            .get("kid")
396            .and_then(serde_json::Value::as_str)
397            .map(str::to_owned);
398        Some(ProtectedHeader { alg, kid })
399    }
400
401    /// Parse `material` (JWK JSON or PEM `SubjectPublicKeyInfo`) into a P-256 verifying key.
402    fn parse_verifying_key(material: &str) -> Result<p256::ecdsa::VerifyingKey, String> {
403        use p256::pkcs8::DecodePublicKey;
404
405        let trimmed = material.trim();
406        if trimmed.starts_with("-----BEGIN") {
407            return p256::ecdsa::VerifyingKey::from_public_key_pem(trimmed)
408                .map_err(|e| format!("PEM public key: {e}"));
409        }
410
411        let jwk: serde_json::Value = serde_json::from_str(trimmed)
412            .map_err(|e| format!("key_material is neither PEM nor valid JWK JSON: {e}"))?;
413        let x = jwk
414            .get("x")
415            .and_then(serde_json::Value::as_str)
416            .ok_or("JWK missing 'x' coordinate")?;
417        let y = jwk
418            .get("y")
419            .and_then(serde_json::Value::as_str)
420            .ok_or("JWK missing 'y' coordinate")?;
421        let x_bytes = URL_SAFE_NO_PAD
422            .decode(x)
423            .map_err(|e| format!("JWK 'x' is not valid base64url: {e}"))?;
424        let y_bytes = URL_SAFE_NO_PAD
425            .decode(y)
426            .map_err(|e| format!("JWK 'y' is not valid base64url: {e}"))?;
427
428        let mut sec1 = Vec::with_capacity(1 + x_bytes.len() + y_bytes.len());
429        sec1.push(0x04); // SEC1 uncompressed point tag.
430        sec1.extend_from_slice(&x_bytes);
431        sec1.extend_from_slice(&y_bytes);
432        p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1)
433            .map_err(|e| format!("invalid P-256 point: {e}"))
434    }
435
436    pub(super) fn verify_one(
437        payload: &[u8],
438        sig: &AgentCardSignature,
439        trusted_keys: &[TrustedKey],
440    ) -> SigOutcome {
441        let Some(header) = decode_protected_header(&sig.protected) else {
442            return SigOutcome::Unverifiable("malformed protected header".to_owned());
443        };
444        let Some(alg) = SigAlg::from_jws_alg(&header.alg) else {
445            return SigOutcome::Unverifiable(format!("unsupported alg '{}'", header.alg));
446        };
447        let Some(kid) = header.kid else {
448            return SigOutcome::Unverifiable("protected header missing 'kid'".to_owned());
449        };
450        let Some(key) = trusted_keys.iter().find(|k| k.kid == kid && k.alg == alg) else {
451            return SigOutcome::Unverifiable(format!("no trusted key for kid '{kid}'"));
452        };
453        let verifying_key = match parse_verifying_key(&key.key_material) {
454            Ok(vk) => vk,
455            Err(e) => return SigOutcome::Invalid(format!("trusted key '{kid}' unparsable: {e}")),
456        };
457
458        let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(&sig.signature) else {
459            return SigOutcome::Invalid("signature is not valid base64url".to_owned());
460        };
461        let Ok(ecdsa_sig) = p256::ecdsa::Signature::from_slice(&sig_bytes) else {
462            return SigOutcome::Invalid(
463                "signature has invalid length/encoding for ES256".to_owned(),
464            );
465        };
466
467        let signing_input = format!("{}.{}", sig.protected, URL_SAFE_NO_PAD.encode(payload));
468        match verifying_key.verify(signing_input.as_bytes(), &ecdsa_sig) {
469            Ok(()) => SigOutcome::Verified,
470            Err(_) => SigOutcome::Invalid("ECDSA verification failed".to_owned()),
471        }
472    }
473}
474
475#[cfg(feature = "card-signing")]
476use imp::verify_one;
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn sig_alg_from_jws_alg() {
484        assert_eq!(SigAlg::from_jws_alg("ES256"), Some(SigAlg::Es256));
485        assert_eq!(SigAlg::from_jws_alg("EdDSA"), None);
486        assert_eq!(SigAlg::from_jws_alg("none"), None);
487    }
488
489    #[test]
490    fn verify_empty_signatures_is_unverifiable_or_disabled() {
491        let raw = serde_json::json!({"name": "peer"});
492        let result = verify_card_signatures(&raw, &[], &[]);
493        assert!(matches!(
494            result,
495            SignatureVerification::Unverifiable { .. } | SignatureVerification::FeatureDisabled
496        ));
497    }
498
499    #[cfg(feature = "card-signing")]
500    mod crypto {
501        use std::assert_matches;
502
503        use p256::ecdsa::SigningKey;
504        use p256::pkcs8::EncodePublicKey;
505
506        use super::super::*;
507
508        fn test_signing_key() -> SigningKey {
509            SigningKey::from_bytes(&[9u8; 32].into()).expect("valid scalar")
510        }
511
512        fn trusted_key_for(kid: &str, signing_key: &SigningKey) -> TrustedKey {
513            let verifying_key = signing_key.verifying_key();
514            let pem = verifying_key
515                .to_public_key_pem(p256::pkcs8::LineEnding::LF)
516                .expect("pem encode");
517            TrustedKey {
518                kid: kid.to_owned(),
519                alg: SigAlg::Es256,
520                key_material: pem,
521            }
522        }
523
524        #[test]
525        fn self_signed_round_trip_verifies() {
526            let signing_key = test_signing_key();
527            let raw_card = serde_json::json!({
528                "name": "peer-agent",
529                "url": "http://peer.example.com",
530                "description": "",
531            });
532            let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
533            let card_with_sig = {
534                let mut v = raw_card.clone();
535                v["signatures"] = serde_json::json!([&sig]);
536                v
537            };
538            let trusted = vec![trusted_key_for("key-1", &signing_key)];
539            let result = verify_card_signatures(&card_with_sig, &[sig], &trusted);
540            assert_eq!(result, SignatureVerification::Verified);
541        }
542
543        #[test]
544        fn tampered_signature_is_invalid() {
545            let signing_key = test_signing_key();
546            let raw_card =
547                serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
548            let mut sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
549            sig.signature = URL_SAFE_NO_PAD.encode([0u8; 64]);
550            let trusted = vec![trusted_key_for("key-1", &signing_key)];
551            let result = verify_card_signatures(&raw_card, &[sig], &trusted);
552            assert_matches!(result, SignatureVerification::Invalid { .. });
553        }
554
555        #[test]
556        fn tampered_payload_is_invalid() {
557            let signing_key = test_signing_key();
558            let raw_card =
559                serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
560            let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
561            let mut tampered_card = raw_card.clone();
562            tampered_card["name"] = serde_json::json!("evil-agent");
563            let trusted = vec![trusted_key_for("key-1", &signing_key)];
564            let result = verify_card_signatures(&tampered_card, &[sig], &trusted);
565            assert_matches!(result, SignatureVerification::Invalid { .. });
566        }
567
568        #[test]
569        fn unknown_kid_is_unverifiable() {
570            let signing_key = test_signing_key();
571            let raw_card =
572                serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
573            let sig = sign_card(&raw_card, "unknown-key", &signing_key).unwrap();
574            let other_key = trusted_key_for("key-1", &test_signing_key());
575            let result = verify_card_signatures(&raw_card, &[sig], &[other_key]);
576            assert_matches!(result, SignatureVerification::Unverifiable { .. });
577        }
578
579        /// Regression test for I1: a tampered-but-trusted-`kid` signature earlier in the
580        /// array must not veto a later signature that verifies — the outcome must be
581        /// order-independent. Models key rotation (old signature tampered/expired, new
582        /// signature valid) and multi-party attestation.
583        #[test]
584        fn verified_signature_wins_regardless_of_position_invalid_then_verified() {
585            let key_a = SigningKey::from_bytes(&[11u8; 32].into()).unwrap();
586            let key_b = SigningKey::from_bytes(&[22u8; 32].into()).unwrap();
587            let raw_card =
588                serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
589
590            let mut sig_a = sign_card(&raw_card, "key-a", &key_a).unwrap();
591            sig_a.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); // tamper: now Invalid
592            let sig_b = sign_card(&raw_card, "key-b", &key_b).unwrap(); // untouched: Verified
593
594            let trusted = vec![
595                trusted_key_for("key-a", &key_a),
596                trusted_key_for("key-b", &key_b),
597            ];
598
599            let result_invalid_first =
600                verify_card_signatures(&raw_card, &[sig_a.clone(), sig_b.clone()], &trusted);
601            assert_eq!(result_invalid_first, SignatureVerification::Verified);
602
603            let result_verified_first =
604                verify_card_signatures(&raw_card, &[sig_b, sig_a], &trusted);
605            assert_eq!(result_verified_first, SignatureVerification::Verified);
606        }
607
608        /// When no signature verifies, `Invalid` must win over `Unverifiable` regardless of
609        /// which entry appears first — a tampered signature is a stronger reject signal than
610        /// an unresolvable one.
611        #[test]
612        fn invalid_wins_over_unverifiable_when_none_verify() {
613            let key_a = SigningKey::from_bytes(&[33u8; 32].into()).unwrap();
614            let raw_card =
615                serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
616
617            let mut sig_a = sign_card(&raw_card, "key-a", &key_a).unwrap();
618            sig_a.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); // trusted kid, tampered → Invalid
619            let sig_unknown = sign_card(&raw_card, "unknown-key", &key_a).unwrap(); // Unverifiable
620
621            let trusted = vec![trusted_key_for("key-a", &key_a)];
622
623            let result = verify_card_signatures(&raw_card, &[sig_unknown, sig_a], &trusted);
624            assert_matches!(result, SignatureVerification::Invalid { .. });
625        }
626
627        /// Regression test for the S1 bug class: JCS **must** canonicalize the raw received
628        /// JSON, never a re-serialization of the typed `AgentCard` struct. The typed struct
629        /// silently drops any JSON key it doesn't recognize (no `deny_unknown_fields`, no
630        /// catch-all field) — canonicalizing the typed struct's re-serialization instead of
631        /// the raw bytes would make a genuinely valid signature fail verification whenever a
632        /// peer's card carries a vendor extension field the schema doesn't model.
633        ///
634        /// Before #6201's proto3-default-stripping fix, this test's premise was a *different*
635        /// divergence source (proto3-default fields the raw JSON omitted but the typed
636        /// struct's `Serialize` impl always re-materializes) — that source is now normalized
637        /// away by [`canonical_payload`]'s stripping, so the test uses an irreducible
638        /// divergence (an unknown field) that stripping cannot close.
639        #[test]
640        fn raw_json_canonicalization_differs_from_typed_struct_reserialization() {
641            let raw_json = serde_json::json!({
642                "name": "peer",
643                "description": "a peer agent",
644                "url": "http://peer.example.com",
645                "version": "0.1.0",
646                "protocolVersion": "0.2.1",
647                "capabilities": {"streaming": true},
648                "vendorExtension": {"trustScore": 42},
649            });
650
651            // Deserializing into the typed `AgentCard` and re-serializing silently drops
652            // `vendorExtension` — it has no field to land in.
653            let typed: crate::types::AgentCard = serde_json::from_value(raw_json.clone()).unwrap();
654            let reserialized = serde_json::to_value(&typed).unwrap();
655
656            let raw_canonical = canonical_payload(&raw_json).unwrap();
657            let reserialized_canonical = canonical_payload(&reserialized).unwrap();
658
659            assert_ne!(
660                raw_canonical, reserialized_canonical,
661                "raw and re-serialized-typed-struct canonical bytes must differ when the raw \
662                 JSON carries a field the AgentCard schema doesn't model — if this assertion \
663                 fails, unknown fields are somehow surviving the typed round-trip and this \
664                 test's premise no longer holds"
665            );
666        }
667
668        /// Regression test for #6201: a compliant A2A signer may strip proto3-default-valued
669        /// fields (empty string/`false`/`0`/empty array/object) from the card JSON before JCS
670        /// canonicalization and signing (A2A spec §8.4.1), while the transmitted card still
671        /// carries those defaults explicitly. Before this fix, `canonical_payload` canonicalized
672        /// the raw received JSON verbatim (`signatures` removed only), so a signature computed
673        /// over the signer's default-stripped payload would fail to verify against the full
674        /// transmitted card — a fail-closed availability bug rejecting a genuinely valid,
675        /// untampered card.
676        #[test]
677        fn signature_over_default_stripped_payload_verifies_against_full_transmitted_card() {
678            let signing_key = SigningKey::from_bytes(&[44u8; 32].into()).unwrap();
679
680            // What a compliant signer canonicalizes and signs: proto3-default fields
681            // (`description`, `defaultInputModes`, `pushNotifications`, ...) are absent.
682            let signer_payload = serde_json::json!({
683                "name": "peer-agent",
684                "url": "http://peer.example.com",
685                "version": "0.1.0",
686                "protocolVersion": "0.2.1",
687                "capabilities": {"streaming": true},
688            });
689            let sig = sign_card(&signer_payload, "key-1", &signing_key).unwrap();
690
691            // What actually arrives over the wire: the same card with every proto3-default
692            // field present and explicit.
693            let transmitted_card = serde_json::json!({
694                "name": "peer-agent",
695                "description": "",
696                "url": "http://peer.example.com",
697                "version": "0.1.0",
698                "protocolVersion": "0.2.1",
699                "capabilities": {
700                    "streaming": true,
701                    "pushNotifications": false,
702                    "stateTransitionHistory": false,
703                    "images": false,
704                    "audio": false,
705                    "files": false
706                },
707                "defaultInputModes": [],
708                "defaultOutputModes": [],
709                "skills": [],
710                "signatures": [&sig],
711            });
712
713            let trusted = vec![trusted_key_for("key-1", &signing_key)];
714            let result = verify_card_signatures(&transmitted_card, &[sig], &trusted);
715            assert_eq!(
716                result,
717                SignatureVerification::Verified,
718                "verification must succeed even when the signer stripped proto3-default \
719                 fields before signing but the transmitted card carries them explicitly"
720            );
721        }
722
723        #[test]
724        fn strip_proto3_defaults_removes_nested_object_that_becomes_empty() {
725            let mut value = serde_json::json!({
726                "name": "peer",
727                "capabilities": {"streaming": false, "images": false},
728                "skills": [{"id": "s1", "tags": []}],
729            });
730            strip_proto3_defaults(&mut value);
731            assert_eq!(
732                value,
733                serde_json::json!({
734                    "name": "peer",
735                    "skills": [{"id": "s1"}],
736                }),
737                "an object whose fields are all proto3 defaults must itself be dropped from \
738                 its parent, and array elements must be recursed into (never removed from \
739                 the array itself)"
740            );
741        }
742    }
743}