1use 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
15fn 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
58pub 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}