#[cfg(feature = "card-signing")]
use base64::Engine as _;
#[cfg(feature = "card-signing")]
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::Value;
use crate::types::AgentCardSignature;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigAlg {
Es256,
}
impl SigAlg {
#[must_use]
pub fn from_jws_alg(alg: &str) -> Option<Self> {
match alg {
"ES256" => Some(Self::Es256),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct TrustedKey {
pub kid: String,
pub alg: SigAlg,
pub key_material: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureVerification {
Verified,
Unverifiable {
reason: String,
},
Invalid {
reason: String,
},
FeatureDisabled,
}
#[cfg(feature = "card-signing")]
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CardSigningError {
#[error("JCS canonicalization failed: {0}")]
Canonicalization(String),
#[error("protected header serialization failed: {0}")]
HeaderSerialization(String),
}
#[must_use]
#[allow(clippy::needless_return, unused_variables)]
pub fn verify_card_signatures(
raw_card: &Value,
signatures: &[AgentCardSignature],
trusted_keys: &[TrustedKey],
) -> SignatureVerification {
#[cfg(not(feature = "card-signing"))]
{
return SignatureVerification::FeatureDisabled;
}
#[cfg(feature = "card-signing")]
{
if signatures.is_empty() {
return SignatureVerification::Unverifiable {
reason: "card carries no signatures".to_owned(),
};
}
let payload = match canonical_payload(raw_card) {
Ok(bytes) => bytes,
Err(e) => {
return SignatureVerification::Invalid {
reason: format!("canonicalization failed: {e}"),
};
}
};
let mut last_unverifiable_reason = "no signature verified".to_owned();
let mut invalid_reason: Option<String> = None;
for sig in signatures {
match verify_one(&payload, sig, trusted_keys) {
imp::SigOutcome::Verified => return SignatureVerification::Verified,
imp::SigOutcome::Invalid(reason) => {
invalid_reason.get_or_insert(reason);
}
imp::SigOutcome::Unverifiable(reason) => last_unverifiable_reason = reason,
}
}
match invalid_reason {
Some(reason) => SignatureVerification::Invalid { reason },
None => SignatureVerification::Unverifiable {
reason: last_unverifiable_reason,
},
}
}
}
#[cfg(feature = "card-signing")]
pub fn sign_card(
raw_card: &Value,
kid: &str,
signing_key: &p256::ecdsa::SigningKey,
) -> Result<AgentCardSignature, CardSigningError> {
use p256::ecdsa::signature::Signer;
let payload = canonical_payload(raw_card).map_err(CardSigningError::Canonicalization)?;
let header = serde_json::json!({"alg": "ES256", "kid": kid});
let header_bytes = serde_json::to_vec(&header)
.map_err(|e| CardSigningError::HeaderSerialization(e.to_string()))?;
let protected = URL_SAFE_NO_PAD.encode(header_bytes);
let signing_input = format!("{protected}.{}", URL_SAFE_NO_PAD.encode(&payload));
let signature: p256::ecdsa::Signature = Signer::sign(signing_key, signing_input.as_bytes());
Ok(AgentCardSignature {
protected,
signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
header: None,
})
}
#[cfg(feature = "card-signing")]
fn canonical_payload(raw_card: &Value) -> Result<Vec<u8>, String> {
let mut card = raw_card.clone();
if let Value::Object(map) = &mut card {
map.remove("signatures");
}
strip_proto3_defaults(&mut card);
serde_json_canonicalizer::to_vec(&card).map_err(|e| e.to_string())
}
#[cfg(feature = "card-signing")]
fn is_proto3_default(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
#[cfg(feature = "card-signing")]
fn strip_proto3_defaults(value: &mut Value) {
match value {
Value::Object(map) => {
map.retain(|_, v| {
strip_proto3_defaults(v);
!is_proto3_default(v)
});
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_proto3_defaults(v);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
#[cfg(feature = "card-signing")]
mod imp {
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use p256::ecdsa::signature::Verifier;
use super::{SigAlg, TrustedKey};
use crate::types::AgentCardSignature;
pub(super) enum SigOutcome {
Verified,
Unverifiable(String),
Invalid(String),
}
struct ProtectedHeader {
alg: String,
kid: Option<String>,
}
fn decode_protected_header(protected_b64: &str) -> Option<ProtectedHeader> {
let bytes = URL_SAFE_NO_PAD.decode(protected_b64).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let alg = v.get("alg")?.as_str()?.to_owned();
let kid = v
.get("kid")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
Some(ProtectedHeader { alg, kid })
}
fn parse_verifying_key(material: &str) -> Result<p256::ecdsa::VerifyingKey, String> {
use p256::pkcs8::DecodePublicKey;
let trimmed = material.trim();
if trimmed.starts_with("-----BEGIN") {
return p256::ecdsa::VerifyingKey::from_public_key_pem(trimmed)
.map_err(|e| format!("PEM public key: {e}"));
}
let jwk: serde_json::Value = serde_json::from_str(trimmed)
.map_err(|e| format!("key_material is neither PEM nor valid JWK JSON: {e}"))?;
let x = jwk
.get("x")
.and_then(serde_json::Value::as_str)
.ok_or("JWK missing 'x' coordinate")?;
let y = jwk
.get("y")
.and_then(serde_json::Value::as_str)
.ok_or("JWK missing 'y' coordinate")?;
let x_bytes = URL_SAFE_NO_PAD
.decode(x)
.map_err(|e| format!("JWK 'x' is not valid base64url: {e}"))?;
let y_bytes = URL_SAFE_NO_PAD
.decode(y)
.map_err(|e| format!("JWK 'y' is not valid base64url: {e}"))?;
let mut sec1 = Vec::with_capacity(1 + x_bytes.len() + y_bytes.len());
sec1.push(0x04); sec1.extend_from_slice(&x_bytes);
sec1.extend_from_slice(&y_bytes);
p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1)
.map_err(|e| format!("invalid P-256 point: {e}"))
}
pub(super) fn verify_one(
payload: &[u8],
sig: &AgentCardSignature,
trusted_keys: &[TrustedKey],
) -> SigOutcome {
let Some(header) = decode_protected_header(&sig.protected) else {
return SigOutcome::Unverifiable("malformed protected header".to_owned());
};
let Some(alg) = SigAlg::from_jws_alg(&header.alg) else {
return SigOutcome::Unverifiable(format!("unsupported alg '{}'", header.alg));
};
let Some(kid) = header.kid else {
return SigOutcome::Unverifiable("protected header missing 'kid'".to_owned());
};
let Some(key) = trusted_keys.iter().find(|k| k.kid == kid && k.alg == alg) else {
return SigOutcome::Unverifiable(format!("no trusted key for kid '{kid}'"));
};
let verifying_key = match parse_verifying_key(&key.key_material) {
Ok(vk) => vk,
Err(e) => return SigOutcome::Invalid(format!("trusted key '{kid}' unparsable: {e}")),
};
let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(&sig.signature) else {
return SigOutcome::Invalid("signature is not valid base64url".to_owned());
};
let Ok(ecdsa_sig) = p256::ecdsa::Signature::from_slice(&sig_bytes) else {
return SigOutcome::Invalid(
"signature has invalid length/encoding for ES256".to_owned(),
);
};
let signing_input = format!("{}.{}", sig.protected, URL_SAFE_NO_PAD.encode(payload));
match verifying_key.verify(signing_input.as_bytes(), &ecdsa_sig) {
Ok(()) => SigOutcome::Verified,
Err(_) => SigOutcome::Invalid("ECDSA verification failed".to_owned()),
}
}
}
#[cfg(feature = "card-signing")]
use imp::verify_one;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sig_alg_from_jws_alg() {
assert_eq!(SigAlg::from_jws_alg("ES256"), Some(SigAlg::Es256));
assert_eq!(SigAlg::from_jws_alg("EdDSA"), None);
assert_eq!(SigAlg::from_jws_alg("none"), None);
}
#[test]
fn verify_empty_signatures_is_unverifiable_or_disabled() {
let raw = serde_json::json!({"name": "peer"});
let result = verify_card_signatures(&raw, &[], &[]);
assert!(matches!(
result,
SignatureVerification::Unverifiable { .. } | SignatureVerification::FeatureDisabled
));
}
#[cfg(feature = "card-signing")]
mod crypto {
use std::assert_matches;
use p256::ecdsa::SigningKey;
use p256::pkcs8::EncodePublicKey;
use super::super::*;
fn test_signing_key() -> SigningKey {
SigningKey::from_bytes(&[9u8; 32].into()).expect("valid scalar")
}
fn trusted_key_for(kid: &str, signing_key: &SigningKey) -> TrustedKey {
let verifying_key = signing_key.verifying_key();
let pem = verifying_key
.to_public_key_pem(p256::pkcs8::LineEnding::LF)
.expect("pem encode");
TrustedKey {
kid: kid.to_owned(),
alg: SigAlg::Es256,
key_material: pem,
}
}
#[test]
fn self_signed_round_trip_verifies() {
let signing_key = test_signing_key();
let raw_card = serde_json::json!({
"name": "peer-agent",
"url": "http://peer.example.com",
"description": "",
});
let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
let card_with_sig = {
let mut v = raw_card.clone();
v["signatures"] = serde_json::json!([&sig]);
v
};
let trusted = vec![trusted_key_for("key-1", &signing_key)];
let result = verify_card_signatures(&card_with_sig, &[sig], &trusted);
assert_eq!(result, SignatureVerification::Verified);
}
#[test]
fn tampered_signature_is_invalid() {
let signing_key = test_signing_key();
let raw_card =
serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
let mut sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
sig.signature = URL_SAFE_NO_PAD.encode([0u8; 64]);
let trusted = vec![trusted_key_for("key-1", &signing_key)];
let result = verify_card_signatures(&raw_card, &[sig], &trusted);
assert_matches!(result, SignatureVerification::Invalid { .. });
}
#[test]
fn tampered_payload_is_invalid() {
let signing_key = test_signing_key();
let raw_card =
serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap();
let mut tampered_card = raw_card.clone();
tampered_card["name"] = serde_json::json!("evil-agent");
let trusted = vec![trusted_key_for("key-1", &signing_key)];
let result = verify_card_signatures(&tampered_card, &[sig], &trusted);
assert_matches!(result, SignatureVerification::Invalid { .. });
}
#[test]
fn unknown_kid_is_unverifiable() {
let signing_key = test_signing_key();
let raw_card =
serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
let sig = sign_card(&raw_card, "unknown-key", &signing_key).unwrap();
let other_key = trusted_key_for("key-1", &test_signing_key());
let result = verify_card_signatures(&raw_card, &[sig], &[other_key]);
assert_matches!(result, SignatureVerification::Unverifiable { .. });
}
#[test]
fn verified_signature_wins_regardless_of_position_invalid_then_verified() {
let key_a = SigningKey::from_bytes(&[11u8; 32].into()).unwrap();
let key_b = SigningKey::from_bytes(&[22u8; 32].into()).unwrap();
let raw_card =
serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
let mut sig_a = sign_card(&raw_card, "key-a", &key_a).unwrap();
sig_a.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); let sig_b = sign_card(&raw_card, "key-b", &key_b).unwrap();
let trusted = vec![
trusted_key_for("key-a", &key_a),
trusted_key_for("key-b", &key_b),
];
let result_invalid_first =
verify_card_signatures(&raw_card, &[sig_a.clone(), sig_b.clone()], &trusted);
assert_eq!(result_invalid_first, SignatureVerification::Verified);
let result_verified_first =
verify_card_signatures(&raw_card, &[sig_b, sig_a], &trusted);
assert_eq!(result_verified_first, SignatureVerification::Verified);
}
#[test]
fn invalid_wins_over_unverifiable_when_none_verify() {
let key_a = SigningKey::from_bytes(&[33u8; 32].into()).unwrap();
let raw_card =
serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"});
let mut sig_a = sign_card(&raw_card, "key-a", &key_a).unwrap();
sig_a.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); let sig_unknown = sign_card(&raw_card, "unknown-key", &key_a).unwrap();
let trusted = vec![trusted_key_for("key-a", &key_a)];
let result = verify_card_signatures(&raw_card, &[sig_unknown, sig_a], &trusted);
assert_matches!(result, SignatureVerification::Invalid { .. });
}
#[test]
fn raw_json_canonicalization_differs_from_typed_struct_reserialization() {
let raw_json = serde_json::json!({
"name": "peer",
"description": "a peer agent",
"url": "http://peer.example.com",
"version": "0.1.0",
"protocolVersion": "0.2.1",
"capabilities": {"streaming": true},
"vendorExtension": {"trustScore": 42},
});
let typed: crate::types::AgentCard = serde_json::from_value(raw_json.clone()).unwrap();
let reserialized = serde_json::to_value(&typed).unwrap();
let raw_canonical = canonical_payload(&raw_json).unwrap();
let reserialized_canonical = canonical_payload(&reserialized).unwrap();
assert_ne!(
raw_canonical, reserialized_canonical,
"raw and re-serialized-typed-struct canonical bytes must differ when the raw \
JSON carries a field the AgentCard schema doesn't model — if this assertion \
fails, unknown fields are somehow surviving the typed round-trip and this \
test's premise no longer holds"
);
}
#[test]
fn signature_over_default_stripped_payload_verifies_against_full_transmitted_card() {
let signing_key = SigningKey::from_bytes(&[44u8; 32].into()).unwrap();
let signer_payload = serde_json::json!({
"name": "peer-agent",
"url": "http://peer.example.com",
"version": "0.1.0",
"protocolVersion": "0.2.1",
"capabilities": {"streaming": true},
});
let sig = sign_card(&signer_payload, "key-1", &signing_key).unwrap();
let transmitted_card = serde_json::json!({
"name": "peer-agent",
"description": "",
"url": "http://peer.example.com",
"version": "0.1.0",
"protocolVersion": "0.2.1",
"capabilities": {
"streaming": true,
"pushNotifications": false,
"stateTransitionHistory": false,
"images": false,
"audio": false,
"files": false
},
"defaultInputModes": [],
"defaultOutputModes": [],
"skills": [],
"signatures": [&sig],
});
let trusted = vec![trusted_key_for("key-1", &signing_key)];
let result = verify_card_signatures(&transmitted_card, &[sig], &trusted);
assert_eq!(
result,
SignatureVerification::Verified,
"verification must succeed even when the signer stripped proto3-default \
fields before signing but the transmitted card carries them explicitly"
);
}
#[test]
fn strip_proto3_defaults_removes_nested_object_that_becomes_empty() {
let mut value = serde_json::json!({
"name": "peer",
"capabilities": {"streaming": false, "images": false},
"skills": [{"id": "s1", "tags": []}],
});
strip_proto3_defaults(&mut value);
assert_eq!(
value,
serde_json::json!({
"name": "peer",
"skills": [{"id": "s1"}],
}),
"an object whose fields are all proto3 defaults must itself be dropped from \
its parent, and array elements must be recursed into (never removed from \
the array itself)"
);
}
}
}