agent-ask 0.1.0

Federated public Q&A protocol for AI agents โ€” signed Q/A/Rating, content-addressed, pull federation (Rust port of @p-vbordei/agent-ask)
Documentation
//! Signed artifact build / verify (mirror of `src/artifact.ts`).

use chrono::{SecondsFormat, Utc};
use serde_json::{json, Map, Value};
use uuid::Uuid;

use crate::canonical::{artifact_bytes_for_sig, compute_cid, jcs};
use crate::error::Error;
use crate::identity::{
    did_from_pubkey, from_base64, pubkey_from_did, sign, to_base64, verify_sig, Keypair,
};

pub const PROTOCOL_VERSION: &str = "agent-ask/0.1";

// SPEC ยง2.4: created_at MUST be RFC 3339 UTC second-precision with `Z` suffix.
// Regex on stable Rust regex crate doesn't support lookaround โ€” we use simple
// byte checks (the playbook gotcha for regex no-lookaround).
fn is_canonical_timestamp(s: &str) -> bool {
    if s.len() != 20 {
        return false;
    }
    let b = s.as_bytes();
    let dig = |i: usize| b[i].is_ascii_digit();
    dig(0) && dig(1) && dig(2) && dig(3)
        && b[4] == b'-'
        && dig(5) && dig(6)
        && b[7] == b'-'
        && dig(8) && dig(9)
        && b[10] == b'T'
        && dig(11) && dig(12)
        && b[13] == b':'
        && dig(14) && dig(15)
        && b[16] == b':'
        && dig(17) && dig(18)
        && b[19] == b'Z'
}

fn is_uuid(s: &str) -> bool {
    if s.len() != 36 {
        return false;
    }
    let b = s.as_bytes();
    let is_hex = |c: u8| c.is_ascii_hexdigit();
    [8usize, 13, 18, 23].iter().all(|&i| b[i] == b'-')
        && b.iter().enumerate().all(|(i, &c)| {
            if [8usize, 13, 18, 23].contains(&i) {
                c == b'-'
            } else {
                is_hex(c)
            }
        })
}

fn iso_seconds_now() -> String {
    Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}

/// A signed artifact in JSON-shaped form. Field order matters for byte-identical
/// outputs after JCS, so we use `Map` (with `preserve_order` enabled) when building.
pub type Artifact = Value;

#[derive(Debug, Default)]
pub struct BuildQuestionOpts {
    pub title: String,
    pub body: String,
    pub tags: Vec<String>,
    pub schema_ref: Option<String>,
    pub created_at: Option<String>,
    pub id: Option<String>,
}

#[derive(Debug, Default)]
pub struct BuildAnswerOpts {
    pub question_cid: String,
    pub body: String,
    pub refs: Option<Vec<String>>,
    pub created_at: Option<String>,
    pub id: Option<String>,
}

#[derive(Debug, Default)]
pub struct BuildRatingOpts {
    pub target_cid: String,
    pub score: i32,
    pub rationale: Option<String>,
    pub created_at: Option<String>,
    pub id: Option<String>,
}

pub fn build_question(keypair: &Keypair, opts: BuildQuestionOpts) -> Result<Artifact, Error> {
    let mut m = Map::new();
    m.insert("v".into(), json!(PROTOCOL_VERSION));
    m.insert("kind".into(), json!("question"));
    m.insert("id".into(), json!(opts.id.unwrap_or_else(|| Uuid::new_v4().to_string())));
    m.insert("author_did".into(), json!(keypair.did));
    m.insert("created_at".into(), json!(opts.created_at.unwrap_or_else(iso_seconds_now)));
    m.insert("title".into(), json!(opts.title));
    m.insert("body".into(), json!(opts.body));
    m.insert("tags".into(), json!(opts.tags));
    if let Some(s) = opts.schema_ref {
        m.insert("schema_ref".into(), json!(s));
    }
    finalize(Value::Object(m), keypair)
}

pub fn build_answer(keypair: &Keypair, opts: BuildAnswerOpts) -> Result<Artifact, Error> {
    let mut m = Map::new();
    m.insert("v".into(), json!(PROTOCOL_VERSION));
    m.insert("kind".into(), json!("answer"));
    m.insert("id".into(), json!(opts.id.unwrap_or_else(|| Uuid::new_v4().to_string())));
    m.insert("author_did".into(), json!(keypair.did));
    m.insert("created_at".into(), json!(opts.created_at.unwrap_or_else(iso_seconds_now)));
    m.insert("question_cid".into(), json!(opts.question_cid));
    m.insert("body".into(), json!(opts.body));
    if let Some(refs) = opts.refs {
        if !refs.is_empty() {
            m.insert("refs".into(), json!(refs));
        }
    }
    finalize(Value::Object(m), keypair)
}

pub fn build_rating(keypair: &Keypair, opts: BuildRatingOpts) -> Result<Artifact, Error> {
    if !(opts.score == -1 || opts.score == 0 || opts.score == 1) {
        return Err(Error::Invalid(format!("invalid rating score: {}", opts.score)));
    }
    let mut m = Map::new();
    m.insert("v".into(), json!(PROTOCOL_VERSION));
    m.insert("kind".into(), json!("rating"));
    m.insert("id".into(), json!(opts.id.unwrap_or_else(|| Uuid::new_v4().to_string())));
    m.insert("author_did".into(), json!(keypair.did));
    m.insert("created_at".into(), json!(opts.created_at.unwrap_or_else(iso_seconds_now)));
    m.insert("target_cid".into(), json!(opts.target_cid));
    m.insert("score".into(), json!(opts.score));
    if let Some(r) = opts.rationale {
        m.insert("rationale".into(), json!(r));
    }
    finalize(Value::Object(m), keypair)
}

fn finalize(base: Value, keypair: &Keypair) -> Result<Artifact, Error> {
    let bytes = artifact_bytes_for_sig(&base)?;
    let sig = sign(&bytes, &keypair.private_key);
    let Value::Object(mut m) = base else {
        return Err(Error::Invalid("expected object".into()));
    };
    let mut sig_obj = Map::new();
    sig_obj.insert("alg".into(), json!("ed25519"));
    sig_obj.insert("pubkey".into(), json!(to_base64(&keypair.public_key)));
    sig_obj.insert("sig".into(), json!(to_base64(&sig)));
    m.insert("sig".into(), Value::Object(sig_obj));
    Ok(Value::Object(m))
}

pub fn cid_of(artifact: &Artifact) -> Result<String, Error> {
    Ok(compute_cid(&jcs(artifact)?))
}

#[derive(Debug, Default, Clone)]
pub struct VerifyResult {
    pub ok: bool,
    pub errors: Vec<String>,
}

pub fn verify_artifact(raw: &Value) -> VerifyResult {
    let errors = schema_errors(raw);
    if !errors.is_empty() {
        return VerifyResult { ok: false, errors };
    }
    let mut errors: Vec<String> = Vec::new();
    let obj = raw.as_object().expect("schema validated");

    let author_did = obj.get("author_did").and_then(|v| v.as_str()).unwrap_or("");
    let sig = obj.get("sig").and_then(|v| v.as_object()).expect("schema validated");
    let pubkey_b64 = sig.get("pubkey").and_then(|v| v.as_str()).unwrap_or("");
    let sig_b64 = sig.get("sig").and_then(|v| v.as_str()).unwrap_or("");

    let pubkey_from_sig = match from_base64(pubkey_b64) {
        Ok(v) => v,
        Err(e) => return VerifyResult { ok: false, errors: vec![format!("identity: {e}")] },
    };
    let pubkey_from_author = match pubkey_from_did(author_did) {
        Ok(v) => v,
        Err(e) => return VerifyResult { ok: false, errors: vec![format!("identity: {e}")] },
    };

    if pubkey_from_sig.as_slice() != pubkey_from_author.as_slice() {
        errors.push("identity: sig.pubkey does not match author_did".into());
    }
    match did_from_pubkey(&pubkey_from_author) {
        Ok(d) if d == author_did => {}
        _ => errors.push("identity: author_did does not match did:key of pubkey".into()),
    }

    let sig_bytes = match from_base64(sig_b64) {
        Ok(v) => v,
        Err(e) => return VerifyResult { ok: false, errors: vec![format!("identity: {e}")] },
    };

    let signed = match artifact_bytes_for_sig(raw) {
        Ok(v) => v,
        Err(e) => return VerifyResult { ok: false, errors: vec![format!("canonical: {e}")] },
    };
    if !verify_sig(&sig_bytes, &signed, &pubkey_from_author) {
        errors.push("signature: invalid".into());
    }

    VerifyResult { ok: errors.is_empty(), errors }
}

fn schema_errors(raw: &Value) -> Vec<String> {
    let mut errors = Vec::new();
    let Some(obj) = raw.as_object() else {
        return vec!["schema: artifact must be an object".into()];
    };

    let mut push = |c: bool, m: &str| {
        if !c {
            errors.push(m.into());
        }
    };

    push(obj.get("v").and_then(|v| v.as_str()) == Some(PROTOCOL_VERSION),
        &format!("schema: v must be {PROTOCOL_VERSION:?}"));

    let kind = obj.get("kind").and_then(|v| v.as_str());
    push(matches!(kind, Some("question") | Some("answer") | Some("rating")),
        "schema: kind must be question|answer|rating");

    push(obj.get("id").and_then(|v| v.as_str()).map(is_uuid).unwrap_or(false),
        "schema: id must be a UUID string");

    push(
        obj.get("author_did").and_then(|v| v.as_str())
            .map(|s| s.starts_with("did:key:")).unwrap_or(false),
        "schema: author_did must start with did:key:",
    );

    push(
        obj.get("created_at").and_then(|v| v.as_str())
            .map(is_canonical_timestamp).unwrap_or(false),
        "schema: created_at must be UTC second-precision with Z suffix",
    );

    let sig_ok = obj.get("sig").and_then(|v| v.as_object()).map(|s| {
        s.get("alg").and_then(|v| v.as_str()) == Some("ed25519")
            && s.get("pubkey").and_then(|v| v.as_str()).is_some()
            && s.get("sig").and_then(|v| v.as_str()).is_some()
    }).unwrap_or(false);
    push(sig_ok, "schema: sig must be {alg=ed25519, pubkey, sig}");

    let common: &[&str] = &["v", "kind", "id", "author_did", "created_at", "sig"];

    match kind {
        Some("question") => {
            let title = obj.get("title").and_then(|v| v.as_str());
            push(title.map(|t| (1..=256).contains(&t.chars().count())).unwrap_or(false),
                "schema: title 1..256 chars");
            push(obj.get("body").map(|v| v.is_string()).unwrap_or(false),
                "schema: body must be string");
            push(
                obj.get("tags").and_then(|v| v.as_array())
                    .map(|a| a.iter().all(|t| t.is_string())).unwrap_or(false),
                "schema: tags must be string[]",
            );
            if let Some(r) = obj.get("schema_ref") {
                push(
                    r.as_str().map(|s| s.starts_with("http://") || s.starts_with("https://")).unwrap_or(false),
                    "schema: schema_ref must be a URL",
                );
            }
            strict_keys(obj, &[common, &["title", "body", "tags", "schema_ref"]].concat(), &mut errors);
        }
        Some("answer") => {
            push(obj.get("question_cid").map(|v| v.is_string()).unwrap_or(false),
                "schema: question_cid must be string");
            push(obj.get("body").map(|v| v.is_string()).unwrap_or(false),
                "schema: body must be string");
            if let Some(refs) = obj.get("refs") {
                push(
                    refs.as_array().map(|a| a.iter().all(|r| r.is_string())).unwrap_or(false),
                    "schema: refs must be string[]",
                );
            }
            strict_keys(obj, &[common, &["question_cid", "body", "refs"]].concat(), &mut errors);
        }
        Some("rating") => {
            push(obj.get("target_cid").map(|v| v.is_string()).unwrap_or(false),
                "schema: target_cid must be string");
            let score = obj.get("score").and_then(|v| v.as_i64());
            push(matches!(score, Some(-1) | Some(0) | Some(1)),
                "schema: score must be -1|0|1");
            if let Some(r) = obj.get("rationale") {
                push(r.is_string(), "schema: rationale must be string");
            }
            strict_keys(obj, &[common, &["target_cid", "score", "rationale"]].concat(), &mut errors);
        }
        _ => {}
    }

    errors
}

fn strict_keys(obj: &Map<String, Value>, allowed: &[&str], errors: &mut Vec<String>) {
    let extra: Vec<&str> = obj.keys()
        .map(String::as_str)
        .filter(|k| !allowed.contains(k))
        .collect();
    if !extra.is_empty() {
        let mut sorted = extra;
        sorted.sort();
        errors.push(format!("schema: unexpected fields {sorted:?}"));
    }
}