use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ring::rand::SystemRandom;
#[cfg(test)]
use ring::signature::KeyPair;
use ring::signature::{self, EcdsaKeyPair};
use crate::agent_card::AgentCard;
use crate::error::{A2aError, A2aResult};
use crate::extensions::AgentCardSignature;
pub fn canonicalize(value: &serde_json::Value) -> A2aResult<Vec<u8>> {
let mut buf = Vec::with_capacity(1024);
write_canonical(value, &mut buf)?;
Ok(buf)
}
pub fn canonicalize_card(card: &AgentCard) -> A2aResult<Vec<u8>> {
let value = serde_json::to_value(card)
.map_err(|e| A2aError::internal(format!("card serialization: {e}")))?;
canonicalize(&value)
}
fn write_canonical(value: &serde_json::Value, buf: &mut Vec<u8>) -> A2aResult<()> {
match value {
serde_json::Value::Null => buf.extend_from_slice(b"null"),
serde_json::Value::Bool(b) => {
buf.extend_from_slice(if *b { b"true" } else { b"false" });
}
serde_json::Value::Number(n) => {
let s = n.to_string();
buf.extend_from_slice(s.as_bytes());
}
serde_json::Value::String(s) => {
write_canonical_string(s, buf);
}
serde_json::Value::Array(arr) => {
buf.push(b'[');
for (i, item) in arr.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
write_canonical(item, buf)?;
}
buf.push(b']');
}
serde_json::Value::Object(obj) => {
let mut keys: Vec<&String> = obj.keys().collect();
keys.sort();
buf.push(b'{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
write_canonical_string(key, buf);
buf.push(b':');
if let Some(val) = obj.get(*key) {
write_canonical(val, buf)?;
}
}
buf.push(b'}');
}
}
Ok(())
}
fn write_canonical_string(s: &str, buf: &mut Vec<u8>) {
buf.push(b'"');
for ch in s.chars() {
match ch {
'"' => buf.extend_from_slice(b"\\\""),
'\\' => buf.extend_from_slice(b"\\\\"),
'\x08' => buf.extend_from_slice(b"\\b"),
'\x0c' => buf.extend_from_slice(b"\\f"),
'\n' => buf.extend_from_slice(b"\\n"),
'\r' => buf.extend_from_slice(b"\\r"),
'\t' => buf.extend_from_slice(b"\\t"),
c if (c as u32) < 0x20 => {
let hex = format!("\\u{:04x}", c as u32);
buf.extend_from_slice(hex.as_bytes());
}
c => {
let mut enc = [0u8; 4];
buf.extend_from_slice(c.encode_utf8(&mut enc).as_bytes());
}
}
}
buf.push(b'"');
}
pub fn sign_agent_card(
card: &AgentCard,
pkcs8_key: &[u8],
key_id: Option<&str>,
) -> A2aResult<AgentCardSignature> {
let canonical = canonicalize_card(card)?;
let mut header = serde_json::json!({ "alg": "ES256" });
if let Some(kid) = key_id {
header["kid"] = serde_json::Value::String(kid.to_owned());
}
let header_json = serde_json::to_vec(&header)
.map_err(|e| A2aError::internal(format!("header serialization: {e}")))?;
let protected = URL_SAFE_NO_PAD.encode(&header_json);
let payload_b64 = URL_SAFE_NO_PAD.encode(&canonical);
let signing_input = format!("{protected}.{payload_b64}");
let rng = SystemRandom::new();
let key_pair =
EcdsaKeyPair::from_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8_key, &rng)
.map_err(|e| A2aError::internal(format!("invalid key: {e}")))?;
let sig = key_pair
.sign(&rng, signing_input.as_bytes())
.map_err(|e| A2aError::internal(format!("signing failed: {e}")))?;
let signature = URL_SAFE_NO_PAD.encode(sig.as_ref());
Ok(AgentCardSignature {
protected,
signature,
header: None,
})
}
pub fn verify_agent_card(
card: &AgentCard,
sig: &AgentCardSignature,
public_key_der: &[u8],
) -> A2aResult<()> {
let canonical = canonicalize_card(card)?;
let payload_b64 = URL_SAFE_NO_PAD.encode(&canonical);
let signing_input = format!("{}.{}", sig.protected, payload_b64);
let sig_bytes = URL_SAFE_NO_PAD
.decode(&sig.signature)
.map_err(|e| A2aError::internal(format!("invalid signature encoding: {e}")))?;
let header_bytes = URL_SAFE_NO_PAD
.decode(&sig.protected)
.map_err(|e| A2aError::internal(format!("invalid header encoding: {e}")))?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|e| A2aError::internal(format!("invalid header JSON: {e}")))?;
let alg = header
.get("alg")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| A2aError::internal("missing alg in header"))?;
if alg != "ES256" {
return Err(A2aError::internal(format!("unsupported algorithm: {alg}")));
}
let public_key =
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, public_key_der);
public_key
.verify(signing_input.as_bytes(), &sig_bytes)
.map_err(|_| A2aError::internal("signature verification failed"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_card::{AgentCapabilities, AgentCard, AgentInterface, AgentSkill};
fn minimal_card() -> AgentCard {
AgentCard {
url: None,
name: "Test Agent".into(),
description: "A test agent".into(),
version: "1.0.0".into(),
supported_interfaces: vec![AgentInterface {
url: "https://agent.example.com/rpc".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
default_input_modes: vec!["text/plain".into()],
default_output_modes: vec!["text/plain".into()],
skills: vec![AgentSkill {
id: "echo".into(),
name: "Echo".into(),
description: "Echoes input".into(),
tags: vec!["echo".into()],
examples: None,
input_modes: None,
output_modes: None,
security_requirements: None,
}],
capabilities: AgentCapabilities::none(),
provider: None,
icon_url: None,
documentation_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
#[test]
fn canonicalize_sorted_keys() {
let json: serde_json::Value = serde_json::json!({"z": 1, "a": 2, "m": 3});
let canonical = canonicalize(&json).unwrap();
let s = String::from_utf8(canonical).unwrap();
assert_eq!(s, r#"{"a":2,"m":3,"z":1}"#);
}
#[test]
fn canonicalize_nested_objects() {
let json: serde_json::Value = serde_json::json!({"b": {"z": 1, "a": 2}, "a": [3, 2, 1]});
let canonical = canonicalize(&json).unwrap();
let s = String::from_utf8(canonical).unwrap();
assert_eq!(s, r#"{"a":[3,2,1],"b":{"a":2,"z":1}}"#);
}
#[test]
fn canonicalize_string_escapes() {
let json: serde_json::Value = serde_json::json!({"msg": "hello\nworld"});
let canonical = canonicalize(&json).unwrap();
let s = String::from_utf8(canonical).unwrap();
assert_eq!(s, r#"{"msg":"hello\nworld"}"#);
}
#[test]
fn canonicalize_card_deterministic() {
let card = minimal_card();
let c1 = canonicalize_card(&card).unwrap();
let c2 = canonicalize_card(&card).unwrap();
assert_eq!(c1, c2);
}
#[test]
fn sign_and_verify_agent_card() {
let card = minimal_card();
let rng = SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
let sig = sign_agent_card(&card, pkcs8.as_ref(), Some("test-key")).unwrap();
assert!(!sig.protected.is_empty());
assert!(!sig.signature.is_empty());
let key_pair = EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let pub_key = key_pair.public_key().as_ref();
verify_agent_card(&card, &sig, pub_key).unwrap();
}
#[test]
fn verify_rejects_tampered_card() {
let mut card = minimal_card();
let rng = SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
let sig = sign_agent_card(&card, pkcs8.as_ref(), None).unwrap();
card.name = "Tampered Agent".into();
let key_pair = EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let pub_key = key_pair.public_key().as_ref();
assert!(verify_agent_card(&card, &sig, pub_key).is_err());
}
#[test]
fn canonicalize_string_control_chars() {
let json: serde_json::Value = serde_json::json!({"a": "x\x08y\x0cz\rw"});
let canonical = canonicalize(&json).unwrap();
let s = String::from_utf8(canonical).unwrap();
assert!(s.contains(r"\b"), "should escape backspace: {s}");
assert!(s.contains(r"\f"), "should escape form-feed: {s}");
assert!(s.contains(r"\r"), "should escape carriage-return: {s}");
}
#[test]
fn verify_rejects_unsupported_algorithm() {
let card = minimal_card();
let rng = SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
let key_pair = EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let pub_key = key_pair.public_key().as_ref();
let header = serde_json::json!({"alg": "RS256"});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
let fake_sig = AgentCardSignature {
protected: header_b64,
signature: URL_SAFE_NO_PAD.encode(b"fake-sig-data"),
header: None,
};
let err = verify_agent_card(&card, &fake_sig, pub_key).unwrap_err();
assert!(
err.message.contains("unsupported algorithm"),
"should reject unsupported algorithm: {}",
err.message
);
}
#[test]
fn protected_header_contains_alg_and_kid() {
let card = minimal_card();
let rng = SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
let sig = sign_agent_card(&card, pkcs8.as_ref(), Some("my-key-id")).unwrap();
let header_bytes = URL_SAFE_NO_PAD.decode(&sig.protected).unwrap();
let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
assert_eq!(header["alg"], "ES256");
assert_eq!(header["kid"], "my-key-id");
}
#[test]
fn canonical_space_is_not_escaped() {
let value = serde_json::Value::String("hello world".into());
let bytes = canonicalize(&value).unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"\"hello world\"",
"space (0x20) must not be escaped"
);
}
#[test]
fn canonical_control_char_0x1f_is_escaped() {
let value = serde_json::Value::String("\x1f".into());
let bytes = canonicalize(&value).unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"\"\\u001f\"",
"0x1F must be escaped as \\u001f"
);
}
}