use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use polyc_crypto::Signer;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardConfig {
pub name: String,
pub description: String,
pub url: String,
pub version: String,
}
#[must_use]
pub fn build_card(cfg: &CardConfig) -> Value {
json!({
"name": cfg.name,
"description": cfg.description,
"version": cfg.version,
"supportedInterfaces": [
{
"url": cfg.url,
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
},
"security": [
{ "bearerAuth": [] }
],
"skills": [
{
"id": "converse",
"name": "Converse",
"description": "Hold a multi-turn conversation, using tools where permitted; \
tool calls that require human approval surface as the A2A input-required state.",
"tags": ["chat", "conversation"]
}
]
})
}
#[must_use]
pub fn signed_card(cfg: &CardConfig, signer: &Signer) -> Value {
sign_card(&build_card(cfg), signer)
}
#[must_use]
pub fn sign_card(unsigned: &Value, signer: &Signer) -> Value {
let payload_b64 = URL_SAFE_NO_PAD.encode(canonical_json(unsigned).as_bytes());
let kid = hex::encode(signer.public_key_bytes());
let header = json!({ "alg": "EdDSA", "kid": kid });
let protected_b64 = URL_SAFE_NO_PAD.encode(canonical_json(&header).as_bytes());
let signing_input = format!("{protected_b64}.{payload_b64}");
let sig_b64 = URL_SAFE_NO_PAD.encode(signer.sign(signing_input.as_bytes()));
let mut card = unsigned.clone();
if let Value::Object(map) = &mut card {
map.insert(
"signatures".to_owned(),
json!([{ "protected": protected_b64, "signature": sig_b64 }]),
);
}
card
}
#[must_use]
pub fn verify_card(card: &Value, public_key: &[u8]) -> bool {
let Some(signatures) = card.get("signatures").and_then(Value::as_array) else {
return false;
};
if signatures.is_empty() {
return false;
}
let mut unsigned = card.clone();
if let Value::Object(map) = &mut unsigned {
map.remove("signatures");
}
let payload_b64 = URL_SAFE_NO_PAD.encode(canonical_json(&unsigned).as_bytes());
signatures.iter().any(|entry| {
let (Some(protected), Some(signature)) = (
entry.get("protected").and_then(Value::as_str),
entry.get("signature").and_then(Value::as_str),
) else {
return false;
};
let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(signature) else {
return false;
};
let signing_input = format!("{protected}.{payload_b64}");
polyc_crypto::verify(public_key, signing_input.as_bytes(), &sig_bytes)
})
}
#[must_use]
pub fn verify_self_signed(card: &Value) -> Option<Vec<u8>> {
let first = card
.get("signatures")
.and_then(Value::as_array)?
.first()?
.get("protected")
.and_then(Value::as_str)?;
let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(first).ok()?).ok()?;
let public_key = hex::decode(header.get("kid").and_then(Value::as_str)?).ok()?;
verify_card(card, &public_key).then_some(public_key)
}
fn canonical_json(value: &Value) -> String {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let body = keys
.into_iter()
.map(|k| {
let key = Value::String(k.clone()).to_string();
let val = map.get(k).map_or_else(|| "null".to_owned(), canonical_json);
format!("{key}:{val}")
})
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
Value::Array(items) => {
let body = items
.iter()
.map(canonical_json)
.collect::<Vec<_>>()
.join(",");
format!("[{body}]")
}
scalar => scalar.to_string(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn cfg() -> CardConfig {
CardConfig {
name: "Polychrome".to_owned(),
description: "A polychrome deployment.".to_owned(),
url: "https://agent.example/".to_owned(),
version: "0.1.3".to_owned(),
}
}
#[test]
fn unsigned_card_is_v1_shaped() {
let card = build_card(&cfg());
assert!(card.get("url").is_none());
assert!(card.get("protocolVersion").is_none());
assert_eq!(card["name"], "Polychrome");
assert_eq!(card["version"], "0.1.3");
assert_eq!(
card["supportedInterfaces"][0]["url"],
"https://agent.example/"
);
assert_eq!(card["supportedInterfaces"][0]["protocolBinding"], "JSONRPC");
assert_eq!(card["supportedInterfaces"][0]["protocolVersion"], "1.0");
assert!(card["capabilities"].is_object());
assert!(card["capabilities"].get("stateTransitionHistory").is_none());
assert!(card["skills"].as_array().is_some_and(|s| !s.is_empty()));
assert!(card["defaultInputModes"].as_array().is_some());
assert!(card.get("signatures").is_none());
}
#[test]
fn card_advertises_streaming_but_not_push_notifications() {
let card = build_card(&cfg());
assert_eq!(card["capabilities"]["streaming"], true);
assert_eq!(card["capabilities"]["pushNotifications"], false);
}
#[test]
fn card_advertises_the_bearer_security_scheme() {
let card = build_card(&cfg());
assert_eq!(card["securitySchemes"]["bearerAuth"]["type"], "http");
assert_eq!(card["securitySchemes"]["bearerAuth"]["scheme"], "bearer");
assert!(
card["security"]
.as_array()
.is_some_and(|reqs| reqs.iter().any(|req| req.get("bearerAuth").is_some())),
"card must require the advertised bearerAuth scheme: {card}"
);
}
#[test]
fn signed_card_verifies_against_principal() {
let signer = Signer::from_seed(7);
let card = signed_card(&cfg(), &signer);
assert!(card.get("signatures").is_some());
assert!(verify_card(&card, &signer.public_key_bytes()));
}
#[test]
fn signature_does_not_verify_under_other_key() {
let signer = Signer::from_seed(7);
let other = Signer::from_seed(8);
let card = signed_card(&cfg(), &signer);
assert!(!verify_card(&card, &other.public_key_bytes()));
}
#[test]
fn tampered_card_fails_verification() {
let signer = Signer::from_seed(7);
let mut card = signed_card(&cfg(), &signer);
card["name"] = json!("Impersonator");
assert!(!verify_card(&card, &signer.public_key_bytes()));
}
#[test]
fn unsigned_card_fails_verification() {
let signer = Signer::from_seed(7);
assert!(!verify_card(
&build_card(&cfg()),
&signer.public_key_bytes()
));
}
#[test]
fn canonical_json_sorts_keys_recursively() {
let a = json!({ "b": 1, "a": { "y": 2, "x": 3 } });
let b = json!({ "a": { "x": 3, "y": 2 }, "b": 1 });
assert_eq!(canonical_json(&a), canonical_json(&b));
assert_eq!(canonical_json(&a), r#"{"a":{"x":3,"y":2},"b":1}"#);
}
}