polyc-a2a 2026.9.0

polychrome A2A edge: serves a domain-signed Agent Card and drives message/send tasks onto a turn.
//! The A2A **Agent Card** — a spec-shaped capability document served at
//! `/.well-known/agent-card.json` — and its domain signature.
//!
//! The card advertises who this deployment is and what it can do (its name,
//! service URL, transport, capabilities, and skills). Per the A2A v1.0
//! transport spec the card MAY carry a `signatures` array of JWS objects so a
//! peer can verify it was issued by the holder of a particular key. We mint
//! exactly one such signature from the deployment's ed25519 principal
//! ([`polyc_crypto::Signer`]) — the same identity that backs tool-call
//! provenance — so the card is a *verifiable* statement of identity rather than
//! self-asserted metadata.
//!
//! ## The signature
//!
//! The signing input follows JWS: `BASE64URL(protected) || '.' ||
//! BASE64URL(payload)`, where `payload` is the canonical JSON of the card with
//! its `signatures` field removed, and `protected` is `{"alg":"EdDSA","kid":
//! <public-key-hex>}`. A canonical, key-sorted JSON encoding makes signing and
//! verification agree regardless of map ordering. The
//! signature itself is produced by [`polyc_crypto::Signer::sign`], which is
//! domain-separated under the `polychrome.v1` namespace — so a card signature
//! can never be replayed as a tool-call or approval signature.

use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use polyc_crypto::Signer;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

/// The deployment-specific fields that vary per install. Everything else on the
/// card (protocol version, transport, the minimal skill set) is fixed by this
/// edge's capabilities.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardConfig {
    /// Human-readable agent name (`AgentCard.name`).
    pub name: String,
    /// One-line description of what the agent does (`AgentCard.description`).
    pub description: String,
    /// The absolute URL of this agent's A2A JSON-RPC endpoint (advertised as the
    /// v1.0 `supportedInterfaces[].url`).
    pub url: String,
    /// The agent/deployment version string (`AgentCard.version`).
    pub version: String,
}

/// Build the **unsigned** A2A v1.0 Agent Card document for `cfg`.
///
/// v1.0 shape: the transport block is `supportedInterfaces` (not top-level
/// `url`/`preferredTransport`), there is no `protocolVersion`/`stateTransitionHistory`
/// (both removed), and capabilities are minimal-but-honest — this edge
/// supports `SendStreamingMessage`/`TaskSubscription` (`#371`) alongside plain
/// `SendMessage`, but has no push notifications. It exposes one skill — a
/// general conversation — whose tool calls surface human-approval pauses as
/// the A2A `input-required` state.
///
/// The card also advertises the trust boundary itself: `securitySchemes`
/// names a `bearerAuth` `HTTP` scheme and `security` requires it on every
/// call, matching the bearer check `server::authenticate` enforces on
/// `POST /` — this is a fixed shape of the edge, not a per-deployment choice,
/// so it is advertised regardless of whether an operator has set the token
/// yet (an unconfigured deployment fails every call closed, see
/// `server::authenticate`).
#[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"]
            }
        ]
    })
}

/// Build the unsigned card and attach the deployment's domain signature,
/// returning the spec-shaped, signed document ready to serve.
#[must_use]
pub fn signed_card(cfg: &CardConfig, signer: &Signer) -> Value {
    sign_card(&build_card(cfg), signer)
}

/// Attach a JWS `signatures` entry to `unsigned`, signed by `signer`.
///
/// The `unsigned` value must be a JSON object (the card); a non-object is
/// returned unchanged. See the module docs for the exact signing input.
#[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
}

/// Verify that `card` carries at least one valid signature from `public_key`.
///
/// Recomputes the JWS signing input from the card with its `signatures` field
/// removed and checks each entry against `public_key` via
/// [`polyc_crypto::verify`]. Returns `false` on a missing/empty `signatures`
/// array, a malformed entry, or any verification failure — never panics. This
/// is the check an A2A *client* (a follow-up) runs before trusting a peer card;
/// shipping it now keeps the signing format honest (the round-trip is tested).
#[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)
    })
}

/// Verify a peer's card against the key it advertises, and return that key.
///
/// A2A cards are self-describing: the signing key lives in the `kid` of each
/// JWS `protected` header. A client that has not yet pinned a peer extracts that
/// key, checks the card actually verifies under it ([`verify_card`]), and on
/// success returns the key to pin (trust-on-first-use). This proves the card is
/// internally consistent — issued by the holder of the key it names — not that
/// the key is *trusted*; pinning across calls is the caller's job.
///
/// Returns `None` if the card has no usable signature, the `kid` is not valid
/// hex, or the card does not verify under the advertised key.
#[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)
}

/// Deterministically serialize `value` with object keys sorted, so the bytes a
/// signer commits to are independent of map iteration order (the workspace
/// enables `serde_json`'s order-preserving maps, so declaration order alone is
/// not a stable canonicalization).
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| {
                    // `Value::to_string` JSON-escapes the key string; the value
                    // recurses so nested objects are sorted too.
                    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}]")
        }
        // Scalars: `Display` already emits canonical JSON (escaped strings,
        // bare numbers/bools/null).
        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());
        // v1.0: transport in `supportedInterfaces`, no top-level url/protocolVersion.
        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());
        // Unsigned: no signatures yet.
        assert!(card.get("signatures").is_none());
    }

    /// `#371`: the card advertises `streaming: true` — this edge implements
    /// `SendStreamingMessage`/`TaskSubscription` — while `pushNotifications`
    /// stays `false` (webhook delivery is out of scope).
    #[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}"#);
    }
}