use std::collections::BTreeMap;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::types::JsonObject;
use super::security::{SecurityRequirement, SecurityScheme};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCard {
pub name: String,
pub description: String,
pub supported_interfaces: Vec<AgentInterface>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<AgentProvider>,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documentation_url: Option<String>,
pub capabilities: AgentCapabilities,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub security_schemes: BTreeMap<String, SecurityScheme>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security_requirements: Vec<SecurityRequirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub default_input_modes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub default_output_modes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<AgentSkill>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signatures: Vec<AgentCardSignature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentInterface {
pub url: String,
pub protocol_binding: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
pub protocol_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProvider {
pub url: String,
pub organization: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub streaming: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push_notifications: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<AgentExtension>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extended_agent_card: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentExtension {
pub uri: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "crate::types::is_false")]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<JsonObject>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSkill {
pub id: String,
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub input_modes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output_modes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security_requirements: Vec<SecurityRequirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCardSignature {
pub protected: String,
pub signature: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<JsonObject>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JwsProtectedHeader {
pub alg: String,
pub kid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub typ: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jku: Option<String>,
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentCardSignatureVerificationInput {
pub protected_header: JwsProtectedHeader,
pub protected_segment: String,
pub signature: Vec<u8>,
pub signing_input: Vec<u8>,
pub unprotected_header: Option<JsonObject>,
}
#[derive(Debug, Error)]
pub enum AgentCardSignatureError {
#[error("agent card does not contain any signatures")]
MissingSignatures,
#[error("invalid protected header encoding: {0}")]
InvalidProtectedEncoding(String),
#[error("invalid signature encoding: {0}")]
InvalidSignatureEncoding(String),
#[error("invalid protected header JSON: {0}")]
InvalidProtectedHeader(#[source] serde_json::Error),
#[error("no agent-card signature matched the supported algorithms")]
UnsupportedAlgorithm,
#[error("agent-card signature verification failed")]
VerificationFailed,
#[error("agent-card serialization failed: {0}")]
Serialization(#[from] serde_json::Error),
}
impl AgentCard {
pub fn unsigned_clone(&self) -> Self {
let mut card = self.clone();
card.signatures.clear();
card
}
pub fn canonical_signing_payload(&self) -> Result<String, AgentCardSignatureError> {
canonicalize_json(&serde_json::to_value(self.unsigned_clone())?)
}
pub fn verify_signatures<F>(
&self,
supported_algorithms: &[&str],
mut verifier: F,
) -> Result<(), AgentCardSignatureError>
where
F: FnMut(&AgentCardSignatureVerificationInput) -> Result<bool, AgentCardSignatureError>,
{
if self.signatures.is_empty() {
return Err(AgentCardSignatureError::MissingSignatures);
}
let mut matched_algorithm = false;
for signature in &self.signatures {
let input = signature.verification_input(self)?;
if !supported_algorithms.is_empty()
&& !supported_algorithms.iter().any(|algorithm| {
algorithm.eq_ignore_ascii_case(input.protected_header.alg.as_str())
})
{
continue;
}
matched_algorithm = true;
if verifier(&input)? {
return Ok(());
}
}
if !matched_algorithm {
return Err(AgentCardSignatureError::UnsupportedAlgorithm);
}
Err(AgentCardSignatureError::VerificationFailed)
}
}
impl AgentCardSignature {
pub fn protected_header(&self) -> Result<JwsProtectedHeader, AgentCardSignatureError> {
let bytes = base64_url_engine()
.decode(self.protected.as_bytes())
.map_err(|error| {
AgentCardSignatureError::InvalidProtectedEncoding(error.to_string())
})?;
serde_json::from_slice(&bytes).map_err(AgentCardSignatureError::InvalidProtectedHeader)
}
pub fn signature_bytes(&self) -> Result<Vec<u8>, AgentCardSignatureError> {
base64_url_engine()
.decode(self.signature.as_bytes())
.map_err(|error| AgentCardSignatureError::InvalidSignatureEncoding(error.to_string()))
}
pub fn verification_input(
&self,
card: &AgentCard,
) -> Result<AgentCardSignatureVerificationInput, AgentCardSignatureError> {
let protected_header = self.protected_header()?;
let signature = self.signature_bytes()?;
let payload = card.canonical_signing_payload()?;
let payload_segment = base64_url_engine().encode(payload.as_bytes());
let signing_input = format!("{}.{}", self.protected, payload_segment).into_bytes();
Ok(AgentCardSignatureVerificationInput {
protected_header,
protected_segment: self.protected.clone(),
signature,
signing_input,
unprotected_header: self.header.clone(),
})
}
}
fn canonicalize_json(value: &Value) -> Result<String, AgentCardSignatureError> {
match value {
Value::Null => Ok("null".to_owned()),
Value::Bool(value) => Ok(if *value { "true" } else { "false" }.to_owned()),
Value::Number(value) => Ok(value.to_string()),
Value::String(value) => serde_json::to_string(value).map_err(AgentCardSignatureError::from),
Value::Array(values) => {
let mut json = String::from("[");
for (index, value) in values.iter().enumerate() {
if index > 0 {
json.push(',');
}
json.push_str(&canonicalize_json(value)?);
}
json.push(']');
Ok(json)
}
Value::Object(values) => {
let mut keys = values.keys().collect::<Vec<_>>();
keys.sort_unstable();
let mut json = String::from("{");
for (index, key) in keys.into_iter().enumerate() {
if index > 0 {
json.push(',');
}
json.push_str(&serde_json::to_string(key)?);
json.push(':');
json.push_str(&canonicalize_json(&values[key])?);
}
json.push('}');
Ok(json)
}
}
}
fn base64_url_engine() -> &'static base64::engine::GeneralPurpose {
&base64::engine::general_purpose::URL_SAFE_NO_PAD
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
AgentCapabilities, AgentCard, AgentCardSignature, AgentCardSignatureError, AgentExtension,
AgentInterface, AgentSkill, JwsProtectedHeader,
};
use base64::Engine as _;
use serde_json::json;
#[test]
fn agent_card_round_trip_serialization() {
let card = AgentCard {
name: "Echo Agent".to_owned(),
description: "Replies with the same text".to_owned(),
supported_interfaces: vec![AgentInterface {
url: "https://example.com/rpc".to_owned(),
protocol_binding: "JSONRPC".to_owned(),
tenant: None,
protocol_version: "1.0".to_owned(),
}],
provider: None,
version: "0.1.0".to_owned(),
documentation_url: None,
capabilities: AgentCapabilities {
streaming: Some(true),
push_notifications: Some(false),
extensions: vec![AgentExtension {
uri: "https://example.com/ext/streaming".to_owned(),
description: "Streaming support".to_owned(),
required: false,
params: None,
}],
extended_agent_card: Some(false),
},
security_schemes: BTreeMap::new(),
security_requirements: Vec::new(),
default_input_modes: vec!["text/plain".to_owned()],
default_output_modes: vec!["text/plain".to_owned()],
skills: vec![AgentSkill {
id: "echo".to_owned(),
name: "Echo".to_owned(),
description: "Echo back user input".to_owned(),
tags: vec!["utility".to_owned()],
examples: vec!["echo hello".to_owned()],
input_modes: vec!["text/plain".to_owned()],
output_modes: vec!["text/plain".to_owned()],
security_requirements: Vec::new(),
}],
signatures: Vec::new(),
icon_url: None,
};
let json = serde_json::to_string(&card).expect("card should serialize");
let round_trip: AgentCard = serde_json::from_str(&json).expect("card should deserialize");
assert_eq!(round_trip.name, "Echo Agent");
assert_eq!(
round_trip.supported_interfaces[0].protocol_binding,
"JSONRPC"
);
assert_eq!(
round_trip.capabilities.extensions[0].description,
"Streaming support"
);
assert!(!round_trip.capabilities.extensions[0].required);
assert_eq!(round_trip.skills[0].id, "echo");
}
#[test]
fn signature_helper_decodes_protected_header() {
let protected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({
"alg": "ES256",
"kid": "key-1",
"typ": "JOSE",
}))
.expect("header should serialize"),
);
let signature = AgentCardSignature {
protected,
signature: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1_u8, 2, 3]),
header: None,
};
let header = signature
.protected_header()
.expect("protected header should decode");
assert_eq!(
header,
JwsProtectedHeader {
alg: "ES256".to_owned(),
kid: "key-1".to_owned(),
typ: Some("JOSE".to_owned()),
jku: None,
extra: BTreeMap::new(),
}
);
}
#[test]
fn canonical_signing_payload_omits_signatures() {
let mut card = sample_card();
card.signatures.push(sample_signature());
let payload = card
.canonical_signing_payload()
.expect("payload should canonicalize");
assert!(!payload.contains("\"signatures\""));
assert!(payload.starts_with("{\"capabilities\""));
}
#[test]
fn verify_signatures_builds_detached_jws_input() {
let mut card = sample_card();
let signature = sample_signature();
let protected = signature.protected.clone();
card.signatures.push(signature);
let payload_segment = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
card.canonical_signing_payload()
.expect("payload should canonicalize"),
);
let expected_input = format!("{protected}.{payload_segment}");
card.verify_signatures(&["ES256"], |input| {
assert_eq!(input.protected_header.alg, "ES256");
assert_eq!(input.protected_header.kid, "key-1");
assert_eq!(input.signature, vec![1_u8, 2, 3]);
assert_eq!(input.signing_input, expected_input.as_bytes());
Ok(true)
})
.expect("verification should succeed");
}
#[test]
fn verify_signatures_rejects_cards_without_supported_algorithms() {
let mut card = sample_card();
card.signatures.push(sample_signature());
let error = card
.verify_signatures(&["RS256"], |_input| Ok(true))
.expect_err("unsupported algorithms should fail");
assert!(matches!(
error,
AgentCardSignatureError::UnsupportedAlgorithm
));
}
fn sample_card() -> AgentCard {
AgentCard {
name: "Echo Agent".to_owned(),
description: "Replies with the same text".to_owned(),
supported_interfaces: vec![AgentInterface {
url: "https://example.com/rpc".to_owned(),
protocol_binding: "JSONRPC".to_owned(),
tenant: None,
protocol_version: "1.0".to_owned(),
}],
provider: None,
version: "0.1.0".to_owned(),
documentation_url: None,
capabilities: AgentCapabilities {
streaming: Some(true),
push_notifications: Some(false),
extensions: vec![AgentExtension {
uri: "https://example.com/ext/streaming".to_owned(),
description: "Streaming support".to_owned(),
required: false,
params: None,
}],
extended_agent_card: Some(false),
},
security_schemes: BTreeMap::new(),
security_requirements: Vec::new(),
default_input_modes: vec!["text/plain".to_owned()],
default_output_modes: vec!["text/plain".to_owned()],
skills: vec![AgentSkill {
id: "echo".to_owned(),
name: "Echo".to_owned(),
description: "Echo back user input".to_owned(),
tags: vec!["utility".to_owned()],
examples: vec!["echo hello".to_owned()],
input_modes: vec!["text/plain".to_owned()],
output_modes: vec!["text/plain".to_owned()],
security_requirements: Vec::new(),
}],
signatures: Vec::new(),
icon_url: None,
}
}
fn sample_signature() -> AgentCardSignature {
AgentCardSignature {
protected: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({
"alg": "ES256",
"kid": "key-1",
"typ": "JOSE",
}))
.expect("header should serialize"),
),
signature: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1_u8, 2, 3]),
header: None,
}
}
}