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 .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;