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
18type 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 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}