1use qualia_core_db::identifier::parse_did_q42;
38use reqwest::Client;
39use serde::Deserialize;
40
41const WEBIZEN_NS_SUFFIX: &str = ".webizen.network";
44const NS_LABEL_PREFIXES: &[&str] = &["ns1.", "ns2.", "ns3.", "ns4."];
46
47#[derive(Debug)]
51pub struct ResolvedIdentity {
52 pub did: String,
54 pub q42_pointer: Option<u64>,
56 pub webid: Option<String>,
58 pub source: ResolutionSource,
60}
61
62#[derive(Debug, PartialEq)]
63pub enum ResolutionSource {
64 LocalQ42,
66 NsEncoding,
68 QdpHttp,
70 DnsTxt,
72}
73
74pub async fn resolve_qdp_did(domain_or_did: &str) -> Result<String, String> {
78 resolve_identity(domain_or_did).await.map(|r| r.did)
79}
80
81pub async fn resolve_identity(input: &str) -> Result<ResolvedIdentity, String> {
83 let input = input.trim();
84
85 if input.starts_with("did:q42:") {
87 let pointer =
88 parse_did_q42(input.as_bytes()).map_err(|e| format!("Invalid did:q42 URI: {:?}", e))?;
89 return Ok(ResolvedIdentity {
90 did: input.to_string(),
91 q42_pointer: Some(pointer),
92 webid: None,
93 source: ResolutionSource::LocalQ42,
94 });
95 }
96
97 if input.starts_with("did:") {
99 return Ok(ResolvedIdentity {
100 did: input.to_string(),
101 q42_pointer: None,
102 webid: None,
103 source: ResolutionSource::LocalQ42,
104 });
105 }
106
107 if let Ok(Some(identity)) = resolve_via_ns_encoding(input).await {
109 return Ok(identity);
110 }
111
112 if let Ok(profile) = fetch_qdp_profile(input).await {
114 let did = profile
115 .front_door_did
116 .or_else(|| profile.webid.clone())
117 .ok_or_else(|| format!("QDP profile at {} has no DID", input))?;
118
119 let q42_pointer = if did.starts_with("did:q42:") {
120 parse_did_q42(did.as_bytes()).ok()
121 } else {
122 None
123 };
124
125 return Ok(ResolvedIdentity {
126 webid: profile.webid,
127 q42_pointer,
128 did,
129 source: ResolutionSource::QdpHttp,
130 });
131 }
132
133 let did = verify_front_door_did_via_dns(input)
135 .await
136 .map_err(|e| format!("All resolution tiers failed for '{}'. Last: {}", input, e))?;
137
138 let q42_pointer = if did.starts_with("did:q42:") {
139 parse_did_q42(did.as_bytes()).ok()
140 } else {
141 None
142 };
143
144 Ok(ResolvedIdentity {
145 did,
146 q42_pointer,
147 webid: None,
148 source: ResolutionSource::DnsTxt,
149 })
150}
151
152async fn resolve_via_ns_encoding(domain: &str) -> Result<Option<ResolvedIdentity>, String> {
159 #[derive(Deserialize)]
160 struct DohResponse {
161 #[serde(rename = "Answer")]
162 answer: Option<Vec<DohRecord>>,
163 }
164 #[derive(Deserialize)]
165 struct DohRecord {
166 #[serde(rename = "type")]
167 record_type: u16,
168 data: String,
169 }
170
171 let url = format!(
172 "https://cloudflare-dns.com/dns-query?name={}&type=NS",
173 domain
174 );
175
176 let client = Client::builder()
177 .timeout(std::time::Duration::from_secs(5))
178 .build()
179 .map_err(|e| e.to_string())?;
180
181 let resp: DohResponse = client
182 .get(&url)
183 .header("Accept", "application/dns-json")
184 .send()
185 .await
186 .map_err(|e| format!("NS DoH request failed: {}", e))?
187 .json()
188 .await
189 .map_err(|e| format!("NS DoH parse failed: {}", e))?;
190
191 const NS_TYPE: u16 = 2;
192 for record in resp.answer.unwrap_or_default() {
193 if record.record_type != NS_TYPE {
194 continue;
195 }
196 let ns = record.data.trim_end_matches('.');
197 if !ns.ends_with(WEBIZEN_NS_SUFFIX) {
198 continue;
199 }
200
201 let without_suffix = &ns[..ns.len() - WEBIZEN_NS_SUFFIX.len()];
203 let payload = NS_LABEL_PREFIXES
204 .iter()
205 .find_map(|prefix| without_suffix.strip_prefix(prefix))
206 .unwrap_or(without_suffix);
207
208 if payload.is_empty() {
209 continue;
210 }
211
212 let did = if payload.starts_with("did:") {
215 payload.to_string()
216 } else {
217 format!("did:q42:{}", payload)
218 };
219
220 let q42_pointer = if did.starts_with("did:q42:") {
221 parse_did_q42(did.as_bytes()).ok()
222 } else {
223 None
224 };
225
226 return Ok(Some(ResolvedIdentity {
227 did,
228 q42_pointer,
229 webid: None,
230 source: ResolutionSource::NsEncoding,
231 }));
232 }
233
234 Ok(None)
235}
236
237#[derive(Debug)]
241pub struct QdpProfile {
242 pub domain: String,
243 pub webid: Option<String>,
245 pub front_door_did: Option<String>,
247 pub raw: String,
248}
249
250pub async fn fetch_qdp_profile(domain: &str) -> Result<QdpProfile, String> {
252 let domain = domain
253 .trim_start_matches("https://")
254 .trim_start_matches("http://");
255
256 let client = Client::builder()
257 .timeout(std::time::Duration::from_secs(10))
258 .build()
259 .map_err(|e| format!("HTTP client error: {}", e))?;
260
261 let response = client
262 .get(format!("https://{}/.well-known/QDP", domain))
263 .header(
264 "Accept",
265 "application/ld+json, text/turtle;q=0.9, */*;q=0.5",
266 )
267 .send()
268 .await
269 .map_err(|e| format!("QDP fetch failed for {}: {}", domain, e))?;
270
271 if !response.status().is_success() {
272 return Err(format!("QDP {} for {}", response.status(), domain));
273 }
274
275 let body = response
276 .text()
277 .await
278 .map_err(|e| format!("QDP body read error: {}", e))?;
279
280 Ok(parse_qdp_body(domain, &body))
281}
282
283fn parse_qdp_body(domain: &str, body: &str) -> QdpProfile {
284 let mut webid: Option<String> = None;
285 let mut front_door_did: Option<String> = None;
286
287 for line in body.lines() {
288 let line = line.trim();
289
290 if line.contains("hasWebID") || line.contains("QDP:webid") || line.contains("foaf:openid") {
291 if let Some(uri) =
292 extract_angle_bracket_uri(line).or_else(|| extract_json_string_uri(line))
293 {
294 if webid.is_none() {
295 webid = Some(uri);
296 }
297 }
298 }
299
300 if line.contains("qdp:signer") || line.contains("QDP:frontDoorDid") {
301 if let Some(did) =
302 extract_angle_bracket_uri(line).or_else(|| extract_did_from_text(line))
303 {
304 front_door_did = Some(did);
305 }
306 }
307
308 if line.contains("did:") {
310 if let Some(did) = extract_did_from_text(line) {
311 if front_door_did.is_none() || did.starts_with("did:q42:") {
312 front_door_did = Some(did);
313 }
314 }
315 }
316 }
317
318 QdpProfile {
319 domain: domain.to_string(),
320 webid,
321 front_door_did,
322 raw: body.to_string(),
323 }
324}
325
326pub async fn verify_front_door_did_via_dns(domain: &str) -> Result<String, String> {
331 #[derive(Deserialize)]
332 struct DohResponse {
333 #[serde(rename = "Answer")]
334 answer: Option<Vec<DohRecord>>,
335 }
336 #[derive(Deserialize)]
337 struct DohRecord {
338 #[serde(rename = "type")]
339 record_type: u16,
340 data: String,
341 }
342
343 let lookup = format!("_qdp.{}", domain.trim_start_matches("_qdp."));
344 let url = format!(
345 "https://cloudflare-dns.com/dns-query?name={}&type=TXT",
346 lookup
347 );
348
349 let client = Client::builder()
350 .timeout(std::time::Duration::from_secs(5))
351 .build()
352 .map_err(|e| e.to_string())?;
353
354 let resp: DohResponse = client
355 .get(&url)
356 .header("Accept", "application/dns-json")
357 .send()
358 .await
359 .map_err(|e| format!("DoH request failed: {}", e))?
360 .json()
361 .await
362 .map_err(|e| format!("DoH parse failed: {}", e))?;
363
364 const TXT: u16 = 16;
365 for record in resp.answer.unwrap_or_default() {
366 if record.record_type == TXT {
367 let txt = record.data.trim_matches('"');
368 if txt.contains("qdp:signer") || txt.contains("did:") {
369 if let Some(did) = extract_did_from_text(txt) {
370 return Ok(did);
371 }
372 }
373 }
374 }
375
376 Err(format!("No Front Door DID in _qdp TXT for {}", domain))
377}
378
379pub fn encode_did_for_ns(did: &str) -> Option<String> {
390 if did.starts_with("did:q42:") {
391 let payload = did.trim_start_matches("did:q42:");
392 let safe: String = payload
396 .chars()
397 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
398 .take(63)
399 .collect::<String>()
400 .to_lowercase();
401 if safe.is_empty() {
402 None
403 } else {
404 Some(safe)
405 }
406 } else {
407 None
408 }
409}
410
411pub fn ns_records_for_did(did: &str) -> Option<(String, String)> {
416 let payload = encode_did_for_ns(did)?;
417 Some((
418 format!("ns1.{}.webizen.network", payload),
419 format!("ns2.{}.webizen.network", payload),
420 ))
421}
422
423fn extract_angle_bracket_uri(text: &str) -> Option<String> {
426 let start = text.find('<')? + 1;
427 let end = text[start..].find('>')? + start;
428 let uri = text[start..end].trim().to_string();
429 if uri.starts_with("http") || uri.starts_with("did:") || uri.starts_with("urn:") {
430 Some(uri)
431 } else {
432 None
433 }
434}
435
436fn extract_json_string_uri(text: &str) -> Option<String> {
437 let colon = text.find(": \"")?;
438 let rest = &text[colon + 3..];
439 let end = rest.find('"')?;
440 let uri = rest[..end].to_string();
441 if uri.starts_with("http") || uri.starts_with("did:") {
442 Some(uri)
443 } else {
444 None
445 }
446}
447
448fn extract_did_from_text(text: &str) -> Option<String> {
449 let start = text.find("did:")?;
450 let rest = &text[start..];
451 let end = rest
452 .find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == ')')
453 .unwrap_or(rest.len());
454 let did = rest[..end].trim_end_matches(['.', ',', ';']).to_string();
455 if did.len() > 4 {
456 Some(did)
457 } else {
458 None
459 }
460}
461
462#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn ns_encoding_roundtrip() {
470 let did = "did:q42:z6MkpTHR8VNs";
471 let (ns1, ns2) = ns_records_for_did(did).unwrap();
472 assert!(ns1.starts_with("ns1."));
473 assert!(ns1.ends_with(WEBIZEN_NS_SUFFIX));
474 assert!(ns2.starts_with("ns2."));
475
476 let without_suffix = &ns1[..ns1.len() - WEBIZEN_NS_SUFFIX.len()];
478 let payload = without_suffix.strip_prefix("ns1.").unwrap();
479 let reconstructed = format!("did:q42:{}", payload);
480 assert!(
482 reconstructed.starts_with("did:q42:z6mk"),
483 "got: {}",
484 reconstructed
485 );
486 }
487
488 #[test]
489 fn encode_did_strips_prefix() {
490 let encoded = encode_did_for_ns("did:q42:z6MkABC").unwrap();
491 assert_eq!(encoded, "z6mkabc");
492 }
493
494 #[test]
495 fn encode_non_q42_returns_none() {
496 assert!(encode_did_for_ns("did:web:example.com").is_none());
497 }
498
499 #[test]
500 fn did_q42_passthrough_is_local() {
501 let input = "did:q42:z6MkpTHR8VNs";
504 assert!(input.starts_with("did:q42:"));
505 }
506}