this repo has no description
1use bytes::Bytes;
2use cid::Cid;
3use jacquard::common::types::crypto::PublicKey;
4use jacquard::common::types::did_doc::DidDocument;
5use jacquard::common::IntoStatic;
6use jacquard_repo::commit::Commit;
7use reqwest::Client;
8use std::collections::HashMap;
9use thiserror::Error;
10use tracing::{debug, warn};
11
12#[derive(Error, Debug)]
13pub enum VerifyError {
14 #[error("Invalid commit: {0}")]
15 InvalidCommit(String),
16 #[error("DID mismatch: commit has {commit_did}, expected {expected_did}")]
17 DidMismatch {
18 commit_did: String,
19 expected_did: String,
20 },
21 #[error("Failed to resolve DID: {0}")]
22 DidResolutionFailed(String),
23 #[error("No signing key found in DID document")]
24 NoSigningKey,
25 #[error("Invalid signature")]
26 InvalidSignature,
27 #[error("MST validation failed: {0}")]
28 MstValidationFailed(String),
29 #[error("Block not found: {0}")]
30 BlockNotFound(String),
31 #[error("Invalid CBOR: {0}")]
32 InvalidCbor(String),
33}
34
35pub struct CarVerifier {
36 http_client: Client,
37}
38
39impl Default for CarVerifier {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl CarVerifier {
46 pub fn new() -> Self {
47 Self {
48 http_client: Client::builder()
49 .timeout(std::time::Duration::from_secs(10))
50 .build()
51 .unwrap_or_default(),
52 }
53 }
54
55 pub async fn verify_car(
56 &self,
57 expected_did: &str,
58 root_cid: &Cid,
59 blocks: &HashMap<Cid, Bytes>,
60 ) -> Result<VerifiedCar, VerifyError> {
61 let root_block = blocks
62 .get(root_cid)
63 .ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
64
65 let commit = Commit::from_cbor(root_block)
66 .map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
67
68 let commit_did = commit.did().as_str();
69 if commit_did != expected_did {
70 return Err(VerifyError::DidMismatch {
71 commit_did: commit_did.to_string(),
72 expected_did: expected_did.to_string(),
73 });
74 }
75
76 let pubkey = self.resolve_did_signing_key(commit_did).await?;
77
78 commit
79 .verify(&pubkey)
80 .map_err(|_| VerifyError::InvalidSignature)?;
81
82 debug!("Commit signature verified for DID {}", commit_did);
83
84 let data_cid = commit.data();
85 self.verify_mst_structure(data_cid, blocks)?;
86
87 debug!("MST structure verified for DID {}", commit_did);
88
89 Ok(VerifiedCar {
90 did: commit_did.to_string(),
91 rev: commit.rev().to_string(),
92 data_cid: *data_cid,
93 prev: commit.prev().cloned(),
94 })
95 }
96
97 async fn resolve_did_signing_key(&self, did: &str) -> Result<PublicKey<'static>, VerifyError> {
98 let did_doc = self.resolve_did_document(did).await?;
99
100 did_doc
101 .atproto_public_key()
102 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?
103 .ok_or(VerifyError::NoSigningKey)
104 }
105
106 async fn resolve_did_document(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
107 if did.starts_with("did:plc:") {
108 self.resolve_plc_did(did).await
109 } else if did.starts_with("did:web:") {
110 self.resolve_web_did(did).await
111 } else {
112 Err(VerifyError::DidResolutionFailed(format!(
113 "Unsupported DID method: {}",
114 did
115 )))
116 }
117 }
118
119 async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
120 let plc_url = std::env::var("PLC_DIRECTORY_URL")
121 .unwrap_or_else(|_| "https://plc.directory".to_string());
122 let url = format!("{}/{}", plc_url, urlencoding::encode(did));
123
124 let response = self
125 .http_client
126 .get(&url)
127 .send()
128 .await
129 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
130
131 if !response.status().is_success() {
132 return Err(VerifyError::DidResolutionFailed(format!(
133 "PLC directory returned {}",
134 response.status()
135 )));
136 }
137
138 let body = response
139 .text()
140 .await
141 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
142
143 let doc: DidDocument<'_> = serde_json::from_str(&body)
144 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
145
146 Ok(doc.into_static())
147 }
148
149 async fn resolve_web_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
150 let domain = did
151 .strip_prefix("did:web:")
152 .ok_or_else(|| VerifyError::DidResolutionFailed("Invalid did:web format".to_string()))?;
153
154 let domain_decoded = urlencoding::decode(domain)
155 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
156
157 let url = if domain_decoded.contains(':') || domain_decoded.contains('/') {
158 format!("https://{}/.well-known/did.json", domain_decoded)
159 } else {
160 format!("https://{}/.well-known/did.json", domain_decoded)
161 };
162
163 let response = self
164 .http_client
165 .get(&url)
166 .send()
167 .await
168 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
169
170 if !response.status().is_success() {
171 return Err(VerifyError::DidResolutionFailed(format!(
172 "did:web resolution returned {}",
173 response.status()
174 )));
175 }
176
177 let body = response
178 .text()
179 .await
180 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
181
182 let doc: DidDocument<'_> = serde_json::from_str(&body)
183 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
184
185 Ok(doc.into_static())
186 }
187
188 fn verify_mst_structure(
189 &self,
190 data_cid: &Cid,
191 blocks: &HashMap<Cid, Bytes>,
192 ) -> Result<(), VerifyError> {
193 use ipld_core::ipld::Ipld;
194
195 let mut stack = vec![*data_cid];
196 let mut visited = std::collections::HashSet::new();
197 let mut node_count = 0;
198 const MAX_NODES: usize = 100_000;
199
200 while let Some(cid) = stack.pop() {
201 if visited.contains(&cid) {
202 continue;
203 }
204 visited.insert(cid);
205 node_count += 1;
206
207 if node_count > MAX_NODES {
208 return Err(VerifyError::MstValidationFailed(
209 "MST exceeds maximum node count".to_string(),
210 ));
211 }
212
213 let block = blocks
214 .get(&cid)
215 .ok_or_else(|| VerifyError::BlockNotFound(cid.to_string()))?;
216
217 let node: Ipld = serde_ipld_dagcbor::from_slice(block)
218 .map_err(|e| VerifyError::InvalidCbor(e.to_string()))?;
219
220 if let Ipld::Map(ref obj) = node {
221 if let Some(Ipld::Link(left_cid)) = obj.get("l") {
222 if !blocks.contains_key(left_cid) {
223 return Err(VerifyError::BlockNotFound(format!(
224 "MST left pointer {} not in CAR",
225 left_cid
226 )));
227 }
228 stack.push(*left_cid);
229 }
230
231 if let Some(Ipld::List(entries)) = obj.get("e") {
232 let mut last_full_key: Vec<u8> = Vec::new();
233
234 for entry in entries {
235 if let Ipld::Map(entry_obj) = entry {
236 let prefix_len = entry_obj.get("p").and_then(|p| match p {
237 Ipld::Integer(i) => Some(*i as usize),
238 _ => None,
239 }).unwrap_or(0);
240
241 let key_suffix = entry_obj.get("k").and_then(|k| match k {
242 Ipld::Bytes(b) => Some(b.clone()),
243 Ipld::String(s) => Some(s.as_bytes().to_vec()),
244 _ => None,
245 });
246
247 if let Some(suffix) = key_suffix {
248 let mut full_key = Vec::new();
249 if prefix_len > 0 && prefix_len <= last_full_key.len() {
250 full_key.extend_from_slice(&last_full_key[..prefix_len]);
251 }
252 full_key.extend_from_slice(&suffix);
253
254 if !last_full_key.is_empty() && full_key <= last_full_key {
255 return Err(VerifyError::MstValidationFailed(
256 "MST keys not in sorted order".to_string(),
257 ));
258 }
259 last_full_key = full_key;
260 }
261
262 if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
263 if !blocks.contains_key(tree_cid) {
264 return Err(VerifyError::BlockNotFound(format!(
265 "MST subtree {} not in CAR",
266 tree_cid
267 )));
268 }
269 stack.push(*tree_cid);
270 }
271
272 if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") {
273 if !blocks.contains_key(value_cid) {
274 warn!(
275 "Record block {} referenced in MST not in CAR (may be expected for partial export)",
276 value_cid
277 );
278 }
279 }
280 }
281 }
282 }
283 }
284 }
285
286 debug!(
287 "MST validation complete: {} nodes, {} blocks visited",
288 node_count,
289 visited.len()
290 );
291
292 Ok(())
293 }
294}
295
296#[derive(Debug, Clone)]
297pub struct VerifiedCar {
298 pub did: String,
299 pub rev: String,
300 pub data_cid: Cid,
301 pub prev: Option<Cid>,
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use sha2::{Digest, Sha256};
308
309 fn make_cid(data: &[u8]) -> Cid {
310 let mut hasher = Sha256::new();
311 hasher.update(data);
312 let hash = hasher.finalize();
313 let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
314 Cid::new_v1(0x71, multihash)
315 }
316
317 #[test]
318 fn test_verifier_creation() {
319 let _verifier = CarVerifier::new();
320 }
321
322 #[test]
323 fn test_verify_error_display() {
324 let err = VerifyError::DidMismatch {
325 commit_did: "did:plc:abc".to_string(),
326 expected_did: "did:plc:xyz".to_string(),
327 };
328 assert!(err.to_string().contains("did:plc:abc"));
329 assert!(err.to_string().contains("did:plc:xyz"));
330
331 let err = VerifyError::InvalidSignature;
332 assert!(err.to_string().contains("signature"));
333
334 let err = VerifyError::NoSigningKey;
335 assert!(err.to_string().contains("signing key"));
336
337 let err = VerifyError::MstValidationFailed("test error".to_string());
338 assert!(err.to_string().contains("test error"));
339 }
340
341 #[test]
342 fn test_mst_validation_missing_root_block() {
343 let verifier = CarVerifier::new();
344 let blocks: HashMap<Cid, Bytes> = HashMap::new();
345
346 let fake_cid = make_cid(b"fake data");
347 let result = verifier.verify_mst_structure(&fake_cid, &blocks);
348
349 assert!(result.is_err());
350 let err = result.unwrap_err();
351 assert!(matches!(err, VerifyError::BlockNotFound(_)));
352 }
353
354 #[test]
355 fn test_mst_validation_invalid_cbor() {
356 let verifier = CarVerifier::new();
357
358 let bad_cbor = Bytes::from(vec![0xFF, 0xFF, 0xFF]);
359 let cid = make_cid(&bad_cbor);
360
361 let mut blocks = HashMap::new();
362 blocks.insert(cid, bad_cbor);
363
364 let result = verifier.verify_mst_structure(&cid, &blocks);
365
366 assert!(result.is_err());
367 let err = result.unwrap_err();
368 assert!(matches!(err, VerifyError::InvalidCbor(_)));
369 }
370
371 #[test]
372 fn test_mst_validation_empty_node() {
373 let verifier = CarVerifier::new();
374
375 let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({
376 "e": []
377 })).unwrap();
378 let cid = make_cid(&empty_node);
379
380 let mut blocks = HashMap::new();
381 blocks.insert(cid, Bytes::from(empty_node));
382
383 let result = verifier.verify_mst_structure(&cid, &blocks);
384 assert!(result.is_ok());
385 }
386
387 #[test]
388 fn test_mst_validation_missing_left_pointer() {
389 use ipld_core::ipld::Ipld;
390
391 let verifier = CarVerifier::new();
392
393 let missing_left_cid = make_cid(b"missing left");
394 let node = Ipld::Map(std::collections::BTreeMap::from([
395 ("l".to_string(), Ipld::Link(missing_left_cid)),
396 ("e".to_string(), Ipld::List(vec![])),
397 ]));
398 let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
399 let cid = make_cid(&node_bytes);
400
401 let mut blocks = HashMap::new();
402 blocks.insert(cid, Bytes::from(node_bytes));
403
404 let result = verifier.verify_mst_structure(&cid, &blocks);
405
406 assert!(result.is_err());
407 let err = result.unwrap_err();
408 assert!(matches!(err, VerifyError::BlockNotFound(_)));
409 assert!(err.to_string().contains("left pointer"));
410 }
411
412 #[test]
413 fn test_mst_validation_missing_subtree() {
414 use ipld_core::ipld::Ipld;
415
416 let verifier = CarVerifier::new();
417
418 let missing_subtree_cid = make_cid(b"missing subtree");
419 let record_cid = make_cid(b"record");
420
421 let entry = Ipld::Map(std::collections::BTreeMap::from([
422 ("k".to_string(), Ipld::Bytes(b"key1".to_vec())),
423 ("v".to_string(), Ipld::Link(record_cid)),
424 ("p".to_string(), Ipld::Integer(0)),
425 ("t".to_string(), Ipld::Link(missing_subtree_cid)),
426 ]));
427
428 let node = Ipld::Map(std::collections::BTreeMap::from([
429 ("e".to_string(), Ipld::List(vec![entry])),
430 ]));
431 let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
432 let cid = make_cid(&node_bytes);
433
434 let mut blocks = HashMap::new();
435 blocks.insert(cid, Bytes::from(node_bytes));
436
437 let result = verifier.verify_mst_structure(&cid, &blocks);
438
439 assert!(result.is_err());
440 let err = result.unwrap_err();
441 assert!(matches!(err, VerifyError::BlockNotFound(_)));
442 assert!(err.to_string().contains("subtree"));
443 }
444
445 #[test]
446 fn test_mst_validation_unsorted_keys() {
447 use ipld_core::ipld::Ipld;
448
449 let verifier = CarVerifier::new();
450
451 let record_cid = make_cid(b"record");
452
453 let entry1 = Ipld::Map(std::collections::BTreeMap::from([
454 ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())),
455 ("v".to_string(), Ipld::Link(record_cid)),
456 ("p".to_string(), Ipld::Integer(0)),
457 ]));
458
459 let entry2 = Ipld::Map(std::collections::BTreeMap::from([
460 ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())),
461 ("v".to_string(), Ipld::Link(record_cid)),
462 ("p".to_string(), Ipld::Integer(0)),
463 ]));
464
465 let node = Ipld::Map(std::collections::BTreeMap::from([
466 ("e".to_string(), Ipld::List(vec![entry1, entry2])),
467 ]));
468 let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
469 let cid = make_cid(&node_bytes);
470
471 let mut blocks = HashMap::new();
472 blocks.insert(cid, Bytes::from(node_bytes));
473
474 let result = verifier.verify_mst_structure(&cid, &blocks);
475
476 assert!(result.is_err());
477 let err = result.unwrap_err();
478 assert!(matches!(err, VerifyError::MstValidationFailed(_)));
479 assert!(err.to_string().contains("sorted"));
480 }
481
482 #[test]
483 fn test_mst_validation_sorted_keys_ok() {
484 use ipld_core::ipld::Ipld;
485
486 let verifier = CarVerifier::new();
487
488 let record_cid = make_cid(b"record");
489
490 let entry1 = Ipld::Map(std::collections::BTreeMap::from([
491 ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())),
492 ("v".to_string(), Ipld::Link(record_cid)),
493 ("p".to_string(), Ipld::Integer(0)),
494 ]));
495
496 let entry2 = Ipld::Map(std::collections::BTreeMap::from([
497 ("k".to_string(), Ipld::Bytes(b"bbb".to_vec())),
498 ("v".to_string(), Ipld::Link(record_cid)),
499 ("p".to_string(), Ipld::Integer(0)),
500 ]));
501
502 let entry3 = Ipld::Map(std::collections::BTreeMap::from([
503 ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())),
504 ("v".to_string(), Ipld::Link(record_cid)),
505 ("p".to_string(), Ipld::Integer(0)),
506 ]));
507
508 let node = Ipld::Map(std::collections::BTreeMap::from([
509 ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])),
510 ]));
511 let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
512 let cid = make_cid(&node_bytes);
513
514 let mut blocks = HashMap::new();
515 blocks.insert(cid, Bytes::from(node_bytes));
516
517 let result = verifier.verify_mst_structure(&cid, &blocks);
518 assert!(result.is_ok());
519 }
520
521 #[test]
522 fn test_mst_validation_with_valid_left_pointer() {
523 use ipld_core::ipld::Ipld;
524
525 let verifier = CarVerifier::new();
526
527 let left_node = Ipld::Map(std::collections::BTreeMap::from([
528 ("e".to_string(), Ipld::List(vec![])),
529 ]));
530 let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap();
531 let left_cid = make_cid(&left_node_bytes);
532
533 let root_node = Ipld::Map(std::collections::BTreeMap::from([
534 ("l".to_string(), Ipld::Link(left_cid)),
535 ("e".to_string(), Ipld::List(vec![])),
536 ]));
537 let root_node_bytes = serde_ipld_dagcbor::to_vec(&root_node).unwrap();
538 let root_cid = make_cid(&root_node_bytes);
539
540 let mut blocks = HashMap::new();
541 blocks.insert(root_cid, Bytes::from(root_node_bytes));
542 blocks.insert(left_cid, Bytes::from(left_node_bytes));
543
544 let result = verifier.verify_mst_structure(&root_cid, &blocks);
545 assert!(result.is_ok());
546 }
547
548 #[test]
549 fn test_mst_validation_cycle_detection() {
550 let verifier = CarVerifier::new();
551
552 let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({
553 "e": []
554 })).unwrap();
555 let cid = make_cid(&node);
556
557 let mut blocks = HashMap::new();
558 blocks.insert(cid, Bytes::from(node));
559
560 let result = verifier.verify_mst_structure(&cid, &blocks);
561 assert!(result.is_ok());
562 }
563
564 #[tokio::test]
565 async fn test_unsupported_did_method() {
566 let verifier = CarVerifier::new();
567 let result = verifier.resolve_did_document("did:unknown:test").await;
568
569 assert!(result.is_err());
570 let err = result.unwrap_err();
571 assert!(matches!(err, VerifyError::DidResolutionFailed(_)));
572 assert!(err.to_string().contains("Unsupported"));
573 }
574
575 #[test]
576 fn test_mst_validation_with_prefix_compression() {
577 use ipld_core::ipld::Ipld;
578
579 let verifier = CarVerifier::new();
580 let record_cid = make_cid(b"record");
581
582 let entry1 = Ipld::Map(std::collections::BTreeMap::from([
583 ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec())),
584 ("v".to_string(), Ipld::Link(record_cid)),
585 ("p".to_string(), Ipld::Integer(0)),
586 ]));
587
588 let entry2 = Ipld::Map(std::collections::BTreeMap::from([
589 ("k".to_string(), Ipld::Bytes(b"def".to_vec())),
590 ("v".to_string(), Ipld::Link(record_cid)),
591 ("p".to_string(), Ipld::Integer(19)),
592 ]));
593
594 let entry3 = Ipld::Map(std::collections::BTreeMap::from([
595 ("k".to_string(), Ipld::Bytes(b"xyz".to_vec())),
596 ("v".to_string(), Ipld::Link(record_cid)),
597 ("p".to_string(), Ipld::Integer(19)),
598 ]));
599
600 let node = Ipld::Map(std::collections::BTreeMap::from([
601 ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])),
602 ]));
603 let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
604 let cid = make_cid(&node_bytes);
605
606 let mut blocks = HashMap::new();
607 blocks.insert(cid, Bytes::from(node_bytes));
608
609 let result = verifier.verify_mst_structure(&cid, &blocks);
610 assert!(result.is_ok(), "Prefix-compressed keys should be validated correctly");
611 }
612
613 #[test]
614 fn test_mst_validation_prefix_compression_unsorted() {
615 use ipld_core::ipld::Ipld;
616
617 let verifier = CarVerifier::new();
618 let record_cid = make_cid(b"record");
619
620 let entry1 = Ipld::Map(std::collections::BTreeMap::from([
621 ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec())),
622 ("v".to_string(), Ipld::Link(record_cid)),
623 ("p".to_string(), Ipld::Integer(0)),
624 ]));
625
626 let entry2 = Ipld::Map(std::collections::BTreeMap::from([
627 ("k".to_string(), Ipld::Bytes(b"abc".to_vec())),
628 ("v".to_string(), Ipld::Link(record_cid)),
629 ("p".to_string(), Ipld::Integer(19)),
630 ]));
631
632 let node = Ipld::Map(std::collections::BTreeMap::from([
633 ("e".to_string(), Ipld::List(vec![entry1, entry2])),
634 ]));
635 let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
636 let cid = make_cid(&node_bytes);
637
638 let mut blocks = HashMap::new();
639 blocks.insert(cid, Bytes::from(node_bytes));
640
641 let result = verifier.verify_mst_structure(&cid, &blocks);
642 assert!(result.is_err(), "Unsorted prefix-compressed keys should fail validation");
643 let err = result.unwrap_err();
644 assert!(matches!(err, VerifyError::MstValidationFailed(_)));
645 }
646}