Skip to main content

agent_cid/
verify.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use chrono::DateTime;
5use once_cell::sync::Lazy;
6use serde_json::{Map, Value};
7
8use crate::canonical::canonical_encode;
9use crate::cid::verify_cid;
10use crate::did::did_key_to_pubkey;
11use crate::did_web::fetch_did_web_pubkey;
12use crate::error::Error;
13use crate::sign::{b64decode, verify_bytes};
14use crate::types::{DidResolver, VerifyOptions, VerifyResult};
15
16const RESOLVER_CACHE_TTL_MS: i64 = 5 * 60 * 1000;
17
18// Cache keyed by resolver pointer + DID. usize key = Arc data pointer.
19type CacheValue = ([u8; 32], i64);
20static RESOLVER_CACHES: Lazy<Mutex<HashMap<usize, HashMap<String, CacheValue>>>> =
21    Lazy::new(|| Mutex::new(HashMap::new()));
22
23async fn builtin_resolve(did: &str) -> Result<[u8; 32], Error> {
24    if did.starts_with("did:key:") {
25        return did_key_to_pubkey(did);
26    }
27    if did.starts_with("did:web:") {
28        return fetch_did_web_pubkey(did).await;
29    }
30    Err(Error::Invalid(format!("unsupported DID method: {did}")))
31}
32
33fn validate_manifest(m: &Value) -> Result<(), Vec<String>> {
34    let mut errors = Vec::new();
35    let Some(obj) = m.as_object() else {
36        return Err(vec!["schema: root not an object".into()]);
37    };
38    if obj.get("v").and_then(|v| v.as_str()) != Some("agent-cid/1") {
39        errors.push("schema: v must be \"agent-cid/1\"".into());
40    }
41    for key in ["cid", "media_type", "schema_uri", "producer", "created_at"] {
42        if !obj.get(key).and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false) {
43            errors.push(format!("schema: {key} must be non-empty string"));
44        }
45    }
46    let size_ok = obj
47        .get("size")
48        .and_then(|v| v.as_u64())
49        .is_some();
50    if !size_ok {
51        errors.push("schema: size must be non-negative integer".into());
52    }
53    let producer = obj.get("producer").and_then(|v| v.as_str()).unwrap_or("");
54    if !producer.starts_with("did:") {
55        errors.push("schema: producer must start with did:".into());
56    }
57    if obj.contains_key("parent_cid")
58        && !obj.get("parent_cid").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false)
59    {
60        errors.push("schema: parent_cid must be non-empty string".into());
61    }
62    let sigs = obj.get("sigs").and_then(|v| v.as_array());
63    match sigs {
64        None => errors.push("schema: sigs must be non-empty array".into()),
65        Some(arr) if arr.is_empty() => errors.push("schema: sigs must be non-empty array".into()),
66        Some(arr) => {
67            for (i, s) in arr.iter().enumerate() {
68                let Some(so) = s.as_object() else {
69                    errors.push(format!("schema: sigs[{i}] not object"));
70                    continue;
71                };
72                let sd = so.get("signer_did").and_then(|v| v.as_str()).unwrap_or("");
73                if !sd.starts_with("did:") {
74                    errors.push(format!("schema: sigs[{i}].signer_did must start with did:"));
75                }
76                if so.get("alg").and_then(|v| v.as_str()) != Some("ed25519") {
77                    errors.push(format!("schema: sigs[{i}].alg must be \"ed25519\""));
78                }
79                if !so.get("sig").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false) {
80                    errors.push(format!("schema: sigs[{i}].sig must be non-empty string"));
81                }
82            }
83        }
84    }
85    if errors.is_empty() {
86        Ok(())
87    } else {
88        Err(errors)
89    }
90}
91
92fn parse_iso_ms(s: &str) -> Option<i64> {
93    DateTime::parse_from_rfc3339(s).ok().map(|d| d.timestamp_millis())
94}
95
96async fn resolve_one(
97    resolver: &Option<DidResolver>,
98    cache_key: Option<usize>,
99    did: &str,
100) -> Result<[u8; 32], Error> {
101    // Check cache.
102    if let Some(key) = cache_key {
103        let now = chrono::Utc::now().timestamp_millis();
104        if let Ok(caches) = RESOLVER_CACHES.lock() {
105            if let Some(entries) = caches.get(&key) {
106                if let Some((pk, exp)) = entries.get(did) {
107                    if *exp > now {
108                        return Ok(*pk);
109                    }
110                }
111            }
112        }
113    }
114
115    let pk = match resolver {
116        Some(r) => {
117            let raw = r(did.to_string()).await?;
118            let arr = <[u8; 32]>::try_from(raw.as_slice())
119                .map_err(|_| Error::Invalid("resolver returned non-32-byte pubkey".into()))?;
120            arr
121        }
122        None => builtin_resolve(did).await?,
123    };
124
125    if let Some(key) = cache_key {
126        let now = chrono::Utc::now().timestamp_millis();
127        if let Ok(mut caches) = RESOLVER_CACHES.lock() {
128            caches
129                .entry(key)
130                .or_default()
131                .insert(did.to_string(), (pk, now + RESOLVER_CACHE_TTL_MS));
132        }
133    }
134
135    Ok(pk)
136}
137
138pub async fn verify(
139    manifest: &Value,
140    data: &[u8],
141    options: &VerifyOptions,
142) -> VerifyResult {
143    let mut errors = Vec::new();
144    let mut warnings = Vec::new();
145
146    if let Err(es) = validate_manifest(manifest) {
147        return VerifyResult { ok: false, errors: es, warnings };
148    }
149
150    let obj = manifest.as_object().unwrap();
151    let now_ms = options.now_ms.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
152
153    let size = obj.get("size").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
154    if size != data.len() {
155        errors.push(format!("size mismatch: manifest {size}, body {}", data.len()));
156    }
157    let cid = obj.get("cid").and_then(|v| v.as_str()).unwrap_or("");
158    if !verify_cid(cid, data) {
159        errors.push("cid mismatch".into());
160    }
161
162    if let Some(retention) = obj.get("retention").and_then(|v| v.as_object()) {
163        if let Some(s) = retention.get("expires_at").and_then(|v| v.as_str()) {
164            if let Some(exp) = parse_iso_ms(s) {
165                if now_ms > exp {
166                    if options.ignore_expiry {
167                        warnings.push(format!("expired at {s} (ignored)"));
168                    } else {
169                        errors.push(format!("expired at {s}"));
170                    }
171                }
172            }
173        }
174        if let Some(s) = retention.get("stale_after").and_then(|v| v.as_str()) {
175            if let Some(stale) = parse_iso_ms(s) {
176                if now_ms > stale {
177                    warnings.push(format!("stale since {s}"));
178                }
179            }
180        }
181    }
182
183    let sigs = obj.get("sigs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
184    let mut unsigned: Map<String, Value> = obj.clone();
185    unsigned.remove("sigs");
186    let canonical = match canonical_encode(&Value::Object(unsigned)) {
187        Ok(b) => b,
188        Err(e) => {
189            errors.push(format!("canonical encode: {e}"));
190            return VerifyResult { ok: false, errors, warnings };
191        }
192    };
193
194    let cache_key = if options.resolver_cache {
195        Some(
196            options
197                .resolver
198                .as_ref()
199                .map(|r| std::sync::Arc::as_ptr(r) as *const () as usize)
200                .unwrap_or(0),
201        )
202    } else {
203        None
204    };
205
206    for (i, s) in sigs.iter().enumerate() {
207        let signer_did = s.get("signer_did").and_then(|v| v.as_str()).unwrap_or("");
208        let sig_b64 = s.get("sig").and_then(|v| v.as_str()).unwrap_or("");
209        let sig_bytes = match b64decode(sig_b64) {
210            Ok(b) => b,
211            Err(e) => {
212                errors.push(format!("sigs[{i}]: base64 decode: {e}"));
213                continue;
214            }
215        };
216        match resolve_one(&options.resolver, cache_key, signer_did).await {
217            Ok(pk) => {
218                if !verify_bytes(&sig_bytes, &canonical, &pk) {
219                    errors.push(format!("sigs[{i}]: invalid signature for {signer_did}"));
220                }
221            }
222            Err(e) => errors.push(format!("sigs[{i}]: {e}")),
223        }
224    }
225
226    VerifyResult { ok: errors.is_empty(), errors, warnings }
227}
228
229pub async fn verify_chain(
230    chain: &[(Value, Vec<u8>)],
231    options: &VerifyOptions,
232) -> VerifyResult {
233    let mut errors = Vec::new();
234    let mut warnings = Vec::new();
235    let mut prev_cid: Option<String> = None;
236
237    for (i, (m, body)) in chain.iter().enumerate() {
238        let r = verify(m, body, options).await;
239        for w in r.warnings {
240            warnings.push(format!("chain[{i}]: {w}"));
241        }
242        if !r.ok {
243            for e in r.errors {
244                errors.push(format!("chain[{i}]: {e}"));
245            }
246        }
247        if let Some(obj) = m.as_object() {
248            if i > 0 {
249                let got = obj.get("parent_cid").and_then(|v| v.as_str()).map(String::from);
250                if got != prev_cid {
251                    errors.push(format!(
252                        "chain[{i}]: parent_cid mismatch — expected {:?}, got {:?}",
253                        prev_cid.as_deref().unwrap_or(""),
254                        got.as_deref().unwrap_or("<missing>")
255                    ));
256                }
257            }
258            prev_cid = obj.get("cid").and_then(|v| v.as_str()).map(String::from);
259        }
260    }
261
262    VerifyResult { ok: errors.is_empty(), errors, warnings }
263}