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