Skip to main content

agent_ask/
artifact.rs

1//! Signed artifact build / verify (mirror of `src/artifact.ts`).
2
3use chrono::{SecondsFormat, Utc};
4use serde_json::{json, Map, Value};
5use uuid::Uuid;
6
7use crate::canonical::{artifact_bytes_for_sig, compute_cid, jcs};
8use crate::error::Error;
9use crate::identity::{
10    did_from_pubkey, from_base64, pubkey_from_did, sign, to_base64, verify_sig, Keypair,
11};
12
13pub const PROTOCOL_VERSION: &str = "agent-ask/0.1";
14
15// SPEC §2.4: created_at MUST be RFC 3339 UTC second-precision with `Z` suffix.
16// Regex on stable Rust regex crate doesn't support lookaround — we use simple
17// byte checks (the playbook gotcha for regex no-lookaround).
18fn is_canonical_timestamp(s: &str) -> bool {
19    if s.len() != 20 {
20        return false;
21    }
22    let b = s.as_bytes();
23    let dig = |i: usize| b[i].is_ascii_digit();
24    dig(0) && dig(1) && dig(2) && dig(3)
25        && b[4] == b'-'
26        && dig(5) && dig(6)
27        && b[7] == b'-'
28        && dig(8) && dig(9)
29        && b[10] == b'T'
30        && dig(11) && dig(12)
31        && b[13] == b':'
32        && dig(14) && dig(15)
33        && b[16] == b':'
34        && dig(17) && dig(18)
35        && b[19] == b'Z'
36}
37
38fn is_uuid(s: &str) -> bool {
39    if s.len() != 36 {
40        return false;
41    }
42    let b = s.as_bytes();
43    let is_hex = |c: u8| c.is_ascii_hexdigit();
44    [8usize, 13, 18, 23].iter().all(|&i| b[i] == b'-')
45        && b.iter().enumerate().all(|(i, &c)| {
46            if [8usize, 13, 18, 23].contains(&i) {
47                c == b'-'
48            } else {
49                is_hex(c)
50            }
51        })
52}
53
54fn iso_seconds_now() -> String {
55    Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
56}
57
58/// A signed artifact in JSON-shaped form. Field order matters for byte-identical
59/// outputs after JCS, so we use `Map` (with `preserve_order` enabled) when building.
60pub type Artifact = Value;
61
62#[derive(Debug, Default)]
63pub struct BuildQuestionOpts {
64    pub title: String,
65    pub body: String,
66    pub tags: Vec<String>,
67    pub schema_ref: Option<String>,
68    pub created_at: Option<String>,
69    pub id: Option<String>,
70}
71
72#[derive(Debug, Default)]
73pub struct BuildAnswerOpts {
74    pub question_cid: String,
75    pub body: String,
76    pub refs: Option<Vec<String>>,
77    pub created_at: Option<String>,
78    pub id: Option<String>,
79}
80
81#[derive(Debug, Default)]
82pub struct BuildRatingOpts {
83    pub target_cid: String,
84    pub score: i32,
85    pub rationale: Option<String>,
86    pub created_at: Option<String>,
87    pub id: Option<String>,
88}
89
90pub fn build_question(keypair: &Keypair, opts: BuildQuestionOpts) -> Result<Artifact, Error> {
91    let mut m = Map::new();
92    m.insert("v".into(), json!(PROTOCOL_VERSION));
93    m.insert("kind".into(), json!("question"));
94    m.insert("id".into(), json!(opts.id.unwrap_or_else(|| Uuid::new_v4().to_string())));
95    m.insert("author_did".into(), json!(keypair.did));
96    m.insert("created_at".into(), json!(opts.created_at.unwrap_or_else(iso_seconds_now)));
97    m.insert("title".into(), json!(opts.title));
98    m.insert("body".into(), json!(opts.body));
99    m.insert("tags".into(), json!(opts.tags));
100    if let Some(s) = opts.schema_ref {
101        m.insert("schema_ref".into(), json!(s));
102    }
103    finalize(Value::Object(m), keypair)
104}
105
106pub fn build_answer(keypair: &Keypair, opts: BuildAnswerOpts) -> Result<Artifact, Error> {
107    let mut m = Map::new();
108    m.insert("v".into(), json!(PROTOCOL_VERSION));
109    m.insert("kind".into(), json!("answer"));
110    m.insert("id".into(), json!(opts.id.unwrap_or_else(|| Uuid::new_v4().to_string())));
111    m.insert("author_did".into(), json!(keypair.did));
112    m.insert("created_at".into(), json!(opts.created_at.unwrap_or_else(iso_seconds_now)));
113    m.insert("question_cid".into(), json!(opts.question_cid));
114    m.insert("body".into(), json!(opts.body));
115    if let Some(refs) = opts.refs {
116        if !refs.is_empty() {
117            m.insert("refs".into(), json!(refs));
118        }
119    }
120    finalize(Value::Object(m), keypair)
121}
122
123pub fn build_rating(keypair: &Keypair, opts: BuildRatingOpts) -> Result<Artifact, Error> {
124    if !(opts.score == -1 || opts.score == 0 || opts.score == 1) {
125        return Err(Error::Invalid(format!("invalid rating score: {}", opts.score)));
126    }
127    let mut m = Map::new();
128    m.insert("v".into(), json!(PROTOCOL_VERSION));
129    m.insert("kind".into(), json!("rating"));
130    m.insert("id".into(), json!(opts.id.unwrap_or_else(|| Uuid::new_v4().to_string())));
131    m.insert("author_did".into(), json!(keypair.did));
132    m.insert("created_at".into(), json!(opts.created_at.unwrap_or_else(iso_seconds_now)));
133    m.insert("target_cid".into(), json!(opts.target_cid));
134    m.insert("score".into(), json!(opts.score));
135    if let Some(r) = opts.rationale {
136        m.insert("rationale".into(), json!(r));
137    }
138    finalize(Value::Object(m), keypair)
139}
140
141fn finalize(base: Value, keypair: &Keypair) -> Result<Artifact, Error> {
142    let bytes = artifact_bytes_for_sig(&base)?;
143    let sig = sign(&bytes, &keypair.private_key);
144    let Value::Object(mut m) = base else {
145        return Err(Error::Invalid("expected object".into()));
146    };
147    let mut sig_obj = Map::new();
148    sig_obj.insert("alg".into(), json!("ed25519"));
149    sig_obj.insert("pubkey".into(), json!(to_base64(&keypair.public_key)));
150    sig_obj.insert("sig".into(), json!(to_base64(&sig)));
151    m.insert("sig".into(), Value::Object(sig_obj));
152    Ok(Value::Object(m))
153}
154
155pub fn cid_of(artifact: &Artifact) -> Result<String, Error> {
156    Ok(compute_cid(&jcs(artifact)?))
157}
158
159#[derive(Debug, Default, Clone)]
160pub struct VerifyResult {
161    pub ok: bool,
162    pub errors: Vec<String>,
163}
164
165pub fn verify_artifact(raw: &Value) -> VerifyResult {
166    let errors = schema_errors(raw);
167    if !errors.is_empty() {
168        return VerifyResult { ok: false, errors };
169    }
170    let mut errors: Vec<String> = Vec::new();
171    let obj = raw.as_object().expect("schema validated");
172
173    let author_did = obj.get("author_did").and_then(|v| v.as_str()).unwrap_or("");
174    let sig = obj.get("sig").and_then(|v| v.as_object()).expect("schema validated");
175    let pubkey_b64 = sig.get("pubkey").and_then(|v| v.as_str()).unwrap_or("");
176    let sig_b64 = sig.get("sig").and_then(|v| v.as_str()).unwrap_or("");
177
178    let pubkey_from_sig = match from_base64(pubkey_b64) {
179        Ok(v) => v,
180        Err(e) => return VerifyResult { ok: false, errors: vec![format!("identity: {e}")] },
181    };
182    let pubkey_from_author = match pubkey_from_did(author_did) {
183        Ok(v) => v,
184        Err(e) => return VerifyResult { ok: false, errors: vec![format!("identity: {e}")] },
185    };
186
187    if pubkey_from_sig.as_slice() != pubkey_from_author.as_slice() {
188        errors.push("identity: sig.pubkey does not match author_did".into());
189    }
190    match did_from_pubkey(&pubkey_from_author) {
191        Ok(d) if d == author_did => {}
192        _ => errors.push("identity: author_did does not match did:key of pubkey".into()),
193    }
194
195    let sig_bytes = match from_base64(sig_b64) {
196        Ok(v) => v,
197        Err(e) => return VerifyResult { ok: false, errors: vec![format!("identity: {e}")] },
198    };
199
200    let signed = match artifact_bytes_for_sig(raw) {
201        Ok(v) => v,
202        Err(e) => return VerifyResult { ok: false, errors: vec![format!("canonical: {e}")] },
203    };
204    if !verify_sig(&sig_bytes, &signed, &pubkey_from_author) {
205        errors.push("signature: invalid".into());
206    }
207
208    VerifyResult { ok: errors.is_empty(), errors }
209}
210
211fn schema_errors(raw: &Value) -> Vec<String> {
212    let mut errors = Vec::new();
213    let Some(obj) = raw.as_object() else {
214        return vec!["schema: artifact must be an object".into()];
215    };
216
217    let mut push = |c: bool, m: &str| {
218        if !c {
219            errors.push(m.into());
220        }
221    };
222
223    push(obj.get("v").and_then(|v| v.as_str()) == Some(PROTOCOL_VERSION),
224        &format!("schema: v must be {PROTOCOL_VERSION:?}"));
225
226    let kind = obj.get("kind").and_then(|v| v.as_str());
227    push(matches!(kind, Some("question") | Some("answer") | Some("rating")),
228        "schema: kind must be question|answer|rating");
229
230    push(obj.get("id").and_then(|v| v.as_str()).map(is_uuid).unwrap_or(false),
231        "schema: id must be a UUID string");
232
233    push(
234        obj.get("author_did").and_then(|v| v.as_str())
235            .map(|s| s.starts_with("did:key:")).unwrap_or(false),
236        "schema: author_did must start with did:key:",
237    );
238
239    push(
240        obj.get("created_at").and_then(|v| v.as_str())
241            .map(is_canonical_timestamp).unwrap_or(false),
242        "schema: created_at must be UTC second-precision with Z suffix",
243    );
244
245    let sig_ok = obj.get("sig").and_then(|v| v.as_object()).map(|s| {
246        s.get("alg").and_then(|v| v.as_str()) == Some("ed25519")
247            && s.get("pubkey").and_then(|v| v.as_str()).is_some()
248            && s.get("sig").and_then(|v| v.as_str()).is_some()
249    }).unwrap_or(false);
250    push(sig_ok, "schema: sig must be {alg=ed25519, pubkey, sig}");
251
252    let common: &[&str] = &["v", "kind", "id", "author_did", "created_at", "sig"];
253
254    match kind {
255        Some("question") => {
256            let title = obj.get("title").and_then(|v| v.as_str());
257            push(title.map(|t| (1..=256).contains(&t.chars().count())).unwrap_or(false),
258                "schema: title 1..256 chars");
259            push(obj.get("body").map(|v| v.is_string()).unwrap_or(false),
260                "schema: body must be string");
261            push(
262                obj.get("tags").and_then(|v| v.as_array())
263                    .map(|a| a.iter().all(|t| t.is_string())).unwrap_or(false),
264                "schema: tags must be string[]",
265            );
266            if let Some(r) = obj.get("schema_ref") {
267                push(
268                    r.as_str().map(|s| s.starts_with("http://") || s.starts_with("https://")).unwrap_or(false),
269                    "schema: schema_ref must be a URL",
270                );
271            }
272            strict_keys(obj, &[common, &["title", "body", "tags", "schema_ref"]].concat(), &mut errors);
273        }
274        Some("answer") => {
275            push(obj.get("question_cid").map(|v| v.is_string()).unwrap_or(false),
276                "schema: question_cid must be string");
277            push(obj.get("body").map(|v| v.is_string()).unwrap_or(false),
278                "schema: body must be string");
279            if let Some(refs) = obj.get("refs") {
280                push(
281                    refs.as_array().map(|a| a.iter().all(|r| r.is_string())).unwrap_or(false),
282                    "schema: refs must be string[]",
283                );
284            }
285            strict_keys(obj, &[common, &["question_cid", "body", "refs"]].concat(), &mut errors);
286        }
287        Some("rating") => {
288            push(obj.get("target_cid").map(|v| v.is_string()).unwrap_or(false),
289                "schema: target_cid must be string");
290            let score = obj.get("score").and_then(|v| v.as_i64());
291            push(matches!(score, Some(-1) | Some(0) | Some(1)),
292                "schema: score must be -1|0|1");
293            if let Some(r) = obj.get("rationale") {
294                push(r.is_string(), "schema: rationale must be string");
295            }
296            strict_keys(obj, &[common, &["target_cid", "score", "rationale"]].concat(), &mut errors);
297        }
298        _ => {}
299    }
300
301    errors
302}
303
304fn strict_keys(obj: &Map<String, Value>, allowed: &[&str], errors: &mut Vec<String>) {
305    let extra: Vec<&str> = obj.keys()
306        .map(String::as_str)
307        .filter(|k| !allowed.contains(k))
308        .collect();
309    if !extra.is_empty() {
310        let mut sorted = extra;
311        sorted.sort();
312        errors.push(format!("schema: unexpected fields {sorted:?}"));
313    }
314}