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)] 305#[path = "verify_tests.rs"] 306mod tests;