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";
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)
}
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:?}"));
}
}