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