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}