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