this repo has no description
1use bytes::Bytes; 2use cid::Cid; 3use jacquard::common::IntoStatic; 4use jacquard::common::types::crypto::PublicKey; 5use jacquard::common::types::did_doc::DidDocument; 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 = 65 Commit::from_cbor(root_block).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.strip_prefix("did:web:").ok_or_else(|| { 137 VerifyError::DidResolutionFailed("Invalid did:web format".to_string()) 138 })?; 139 let domain_decoded = urlencoding::decode(domain) 140 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 141 let url = format!("https://{}/.well-known/did.json", domain_decoded); 142 let response = self 143 .http_client 144 .get(&url) 145 .send() 146 .await 147 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 148 if !response.status().is_success() { 149 return Err(VerifyError::DidResolutionFailed(format!( 150 "did:web resolution returned {}", 151 response.status() 152 ))); 153 } 154 let body = response 155 .text() 156 .await 157 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 158 let doc: DidDocument<'_> = serde_json::from_str(&body) 159 .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; 160 Ok(doc.into_static()) 161 } 162 163 fn verify_mst_structure( 164 &self, 165 data_cid: &Cid, 166 blocks: &HashMap<Cid, Bytes>, 167 ) -> Result<(), VerifyError> { 168 use ipld_core::ipld::Ipld; 169 170 let mut stack = vec![*data_cid]; 171 let mut visited = std::collections::HashSet::new(); 172 let mut node_count = 0; 173 const MAX_NODES: usize = 100_000; 174 while let Some(cid) = stack.pop() { 175 if visited.contains(&cid) { 176 continue; 177 } 178 visited.insert(cid); 179 node_count += 1; 180 if node_count > MAX_NODES { 181 return Err(VerifyError::MstValidationFailed( 182 "MST exceeds maximum node count".to_string(), 183 )); 184 } 185 let block = blocks 186 .get(&cid) 187 .ok_or_else(|| VerifyError::BlockNotFound(cid.to_string()))?; 188 let node: Ipld = serde_ipld_dagcbor::from_slice(block) 189 .map_err(|e| VerifyError::InvalidCbor(e.to_string()))?; 190 if let Ipld::Map(ref obj) = node { 191 if let Some(Ipld::Link(left_cid)) = obj.get("l") { 192 if !blocks.contains_key(left_cid) { 193 return Err(VerifyError::BlockNotFound(format!( 194 "MST left pointer {} not in CAR", 195 left_cid 196 ))); 197 } 198 stack.push(*left_cid); 199 } 200 if let Some(Ipld::List(entries)) = obj.get("e") { 201 let mut last_full_key: Vec<u8> = Vec::new(); 202 for entry in entries { 203 if let Ipld::Map(entry_obj) = entry { 204 let prefix_len = entry_obj 205 .get("p") 206 .and_then(|p| match p { 207 Ipld::Integer(i) => Some(*i as usize), 208 _ => None, 209 }) 210 .unwrap_or(0); 211 let key_suffix = entry_obj.get("k").and_then(|k| match k { 212 Ipld::Bytes(b) => Some(b.clone()), 213 Ipld::String(s) => Some(s.as_bytes().to_vec()), 214 _ => None, 215 }); 216 if let Some(suffix) = key_suffix { 217 let mut full_key = Vec::new(); 218 if prefix_len > 0 && prefix_len <= last_full_key.len() { 219 full_key.extend_from_slice(&last_full_key[..prefix_len]); 220 } 221 full_key.extend_from_slice(&suffix); 222 if !last_full_key.is_empty() && full_key <= last_full_key { 223 return Err(VerifyError::MstValidationFailed( 224 "MST keys not in sorted order".to_string(), 225 )); 226 } 227 last_full_key = full_key; 228 } 229 if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { 230 if !blocks.contains_key(tree_cid) { 231 return Err(VerifyError::BlockNotFound(format!( 232 "MST subtree {} not in CAR", 233 tree_cid 234 ))); 235 } 236 stack.push(*tree_cid); 237 } 238 if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") 239 && !blocks.contains_key(value_cid) { 240 warn!( 241 "Record block {} referenced in MST not in CAR (may be expected for partial export)", 242 value_cid 243 ); 244 } 245 } 246 } 247 } 248 } 249 } 250 debug!( 251 "MST validation complete: {} nodes, {} blocks visited", 252 node_count, 253 visited.len() 254 ); 255 Ok(()) 256 } 257} 258 259#[derive(Debug, Clone)] 260pub struct VerifiedCar { 261 pub did: String, 262 pub rev: String, 263 pub data_cid: Cid, 264 pub prev: Option<Cid>, 265} 266 267#[cfg(test)] 268#[path = "verify_tests.rs"] 269mod tests;