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#[derive(Error, Debug)] 12pub enum VerifyError { 13 #[error("Invalid commit: {0}")] 14 InvalidCommit(String), 15 #[error("DID mismatch: commit has {commit_did}, expected {expected_did}")] 16 DidMismatch { 17 commit_did: String, 18 expected_did: String, 19 }, 20 #[error("Failed to resolve DID: {0}")] 21 DidResolutionFailed(String), 22 #[error("No signing key found in DID document")] 23 NoSigningKey, 24 #[error("Invalid signature")] 25 InvalidSignature, 26 #[error("MST validation failed: {0}")] 27 MstValidationFailed(String), 28 #[error("Block not found: {0}")] 29 BlockNotFound(String), 30 #[error("Invalid CBOR: {0}")] 31 InvalidCbor(String), 32} 33pub struct CarVerifier { 34 http_client: Client, 35} 36impl Default for CarVerifier { 37 fn default() -> Self { 38 Self::new() 39 } 40} 41impl CarVerifier { 42 pub fn new() -> Self { 43 Self { 44 http_client: Client::builder() 45 .timeout(std::time::Duration::from_secs(10)) 46 .build() 47 .unwrap_or_default(), 48 } 49 } 50 pub async fn verify_car( 51 &self, 52 expected_did: &str, 53 root_cid: &Cid, 54 blocks: &HashMap<Cid, Bytes>, 55 ) -> Result<VerifiedCar, VerifyError> { 56 let root_block = blocks 57 .get(root_cid) 58 .ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?; 59 let commit = Commit::from_cbor(root_block) 60 .map_err(|e| VerifyError::InvalidCommit(e.to_string()))?; 61 let commit_did = commit.did().as_str(); 62 if commit_did != expected_did { 63 return Err(VerifyError::DidMismatch { 64 commit_did: commit_did.to_string(), 65 expected_did: expected_did.to_string(), 66 }); 67 } 68 let pubkey = self.resolve_did_signing_key(commit_did).await?; 69 commit 70 .verify(&pubkey) 71 .map_err(|_| VerifyError::InvalidSignature)?; 72 debug!("Commit signature verified for DID {}", commit_did); 73 let data_cid = commit.data(); 74 self.verify_mst_structure(data_cid, blocks)?; 75 debug!("MST structure verified for DID {}", commit_did); 76 Ok(VerifiedCar { 77 did: commit_did.to_string(), 78 rev: commit.rev().to_string(), 79 data_cid: *data_cid, 80 prev: commit.prev().cloned(), 81 }) 82 } 83 async fn resolve_did_signing_key(&self, did: &str) -> Result<PublicKey<'static>, VerifyError> { 84 let did_doc = self.resolve_did_document(did).await?; 85 did_doc 86 .atproto_public_key() 87 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))? 88 .ok_or(VerifyError::NoSigningKey) 89 } 90 async fn resolve_did_document(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> { 91 if did.starts_with("did:plc:") { 92 self.resolve_plc_did(did).await 93 } else if did.starts_with("did:web:") { 94 self.resolve_web_did(did).await 95 } else { 96 Err(VerifyError::DidResolutionFailed(format!( 97 "Unsupported DID method: {}", 98 did 99 ))) 100 } 101 } 102 async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> { 103 let plc_url = std::env::var("PLC_DIRECTORY_URL") 104 .unwrap_or_else(|_| "https://plc.directory".to_string()); 105 let url = format!("{}/{}", plc_url, urlencoding::encode(did)); 106 let response = self 107 .http_client 108 .get(&url) 109 .send() 110 .await 111 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 112 if !response.status().is_success() { 113 return Err(VerifyError::DidResolutionFailed(format!( 114 "PLC directory returned {}", 115 response.status() 116 ))); 117 } 118 let body = response 119 .text() 120 .await 121 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 122 let doc: DidDocument<'_> = serde_json::from_str(&body) 123 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 124 Ok(doc.into_static()) 125 } 126 async fn resolve_web_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> { 127 let domain = did 128 .strip_prefix("did:web:") 129 .ok_or_else(|| VerifyError::DidResolutionFailed("Invalid did:web format".to_string()))?; 130 let domain_decoded = urlencoding::decode(domain) 131 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 132 let url = if domain_decoded.contains(':') || domain_decoded.contains('/') { 133 format!("https://{}/.well-known/did.json", domain_decoded) 134 } else { 135 format!("https://{}/.well-known/did.json", domain_decoded) 136 }; 137 let response = self 138 .http_client 139 .get(&url) 140 .send() 141 .await 142 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 143 if !response.status().is_success() { 144 return Err(VerifyError::DidResolutionFailed(format!( 145 "did:web resolution returned {}", 146 response.status() 147 ))); 148 } 149 let body = response 150 .text() 151 .await 152 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 153 let doc: DidDocument<'_> = serde_json::from_str(&body) 154 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 155 Ok(doc.into_static()) 156 } 157 fn verify_mst_structure( 158 &self, 159 data_cid: &Cid, 160 blocks: &HashMap<Cid, Bytes>, 161 ) -> Result<(), VerifyError> { 162 use ipld_core::ipld::Ipld; 163 let mut stack = vec![*data_cid]; 164 let mut visited = std::collections::HashSet::new(); 165 let mut node_count = 0; 166 const MAX_NODES: usize = 100_000; 167 while let Some(cid) = stack.pop() { 168 if visited.contains(&cid) { 169 continue; 170 } 171 visited.insert(cid); 172 node_count += 1; 173 if node_count > MAX_NODES { 174 return Err(VerifyError::MstValidationFailed( 175 "MST exceeds maximum node count".to_string(), 176 )); 177 } 178 let block = blocks 179 .get(&cid) 180 .ok_or_else(|| VerifyError::BlockNotFound(cid.to_string()))?; 181 let node: Ipld = serde_ipld_dagcbor::from_slice(block) 182 .map_err(|e| VerifyError::InvalidCbor(e.to_string()))?; 183 if let Ipld::Map(ref obj) = node { 184 if let Some(Ipld::Link(left_cid)) = obj.get("l") { 185 if !blocks.contains_key(left_cid) { 186 return Err(VerifyError::BlockNotFound(format!( 187 "MST left pointer {} not in CAR", 188 left_cid 189 ))); 190 } 191 stack.push(*left_cid); 192 } 193 if let Some(Ipld::List(entries)) = obj.get("e") { 194 let mut last_full_key: Vec<u8> = Vec::new(); 195 for entry in entries { 196 if let Ipld::Map(entry_obj) = entry { 197 let prefix_len = entry_obj.get("p").and_then(|p| match p { 198 Ipld::Integer(i) => Some(*i as usize), 199 _ => None, 200 }).unwrap_or(0); 201 let key_suffix = entry_obj.get("k").and_then(|k| match k { 202 Ipld::Bytes(b) => Some(b.clone()), 203 Ipld::String(s) => Some(s.as_bytes().to_vec()), 204 _ => None, 205 }); 206 if let Some(suffix) = key_suffix { 207 let mut full_key = Vec::new(); 208 if prefix_len > 0 && prefix_len <= last_full_key.len() { 209 full_key.extend_from_slice(&last_full_key[..prefix_len]); 210 } 211 full_key.extend_from_slice(&suffix); 212 if !last_full_key.is_empty() && full_key <= last_full_key { 213 return Err(VerifyError::MstValidationFailed( 214 "MST keys not in sorted order".to_string(), 215 )); 216 } 217 last_full_key = full_key; 218 } 219 if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { 220 if !blocks.contains_key(tree_cid) { 221 return Err(VerifyError::BlockNotFound(format!( 222 "MST subtree {} not in CAR", 223 tree_cid 224 ))); 225 } 226 stack.push(*tree_cid); 227 } 228 if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") { 229 if !blocks.contains_key(value_cid) { 230 warn!( 231 "Record block {} referenced in MST not in CAR (may be expected for partial export)", 232 value_cid 233 ); 234 } 235 } 236 } 237 } 238 } 239 } 240 } 241 debug!( 242 "MST validation complete: {} nodes, {} blocks visited", 243 node_count, 244 visited.len() 245 ); 246 Ok(()) 247 } 248} 249#[derive(Debug, Clone)] 250pub struct VerifiedCar { 251 pub did: String, 252 pub rev: String, 253 pub data_cid: Cid, 254 pub prev: Option<Cid>, 255} 256#[cfg(test)] 257#[path = "verify_tests.rs"] 258mod tests;