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